summaryrefslogtreecommitdiff
path: root/app/[lng]/sales/(sales)
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-30 08:28:13 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-30 08:28:13 +0000
commit5b6313f16f508882a0ea67716b7dbaa1c6967f04 (patch)
tree3d1d8dafea2f31274ace3fbda08333e889e06d1c /app/[lng]/sales/(sales)
parent3f0fad18483a5c800c79c5e33946d9bb384c10e2 (diff)
(대표님) 20250630 16시 - 유저 도메인별 라우터 분리와 보안성검토 대응
Diffstat (limited to 'app/[lng]/sales/(sales)')
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/[id]/final/page.tsx0
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/[id]/initial/page.tsx52
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/[id]/layout.tsx87
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/[id]/page.tsx53
-rw-r--r--app/[lng]/sales/(sales)/b-rfq/page.tsx79
-rw-r--r--app/[lng]/sales/(sales)/basic-contract-template/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/basic-contract/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/bid-projects/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/bqcbe/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/bqtbe/page.tsx72
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx90
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx57
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/budgetary-rfq/page.tsx86
-rw-r--r--app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx90
-rw-r--r--app/[lng]/sales/(sales)/budgetary/[id]/page.tsx57
-rw-r--r--app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/budgetary/page.tsx86
-rw-r--r--app/[lng]/sales/(sales)/cbe-tech/page.tsx67
-rw-r--r--app/[lng]/sales/(sales)/dashboard/page.tsx17
-rw-r--r--app/[lng]/sales/(sales)/email-template/[name]/page.tsx26
-rw-r--r--app/[lng]/sales/(sales)/email-template/page.tsx19
-rw-r--r--app/[lng]/sales/(sales)/equip-class/page.tsx75
-rw-r--r--app/[lng]/sales/(sales)/esg-check-list/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/evaluation-check-list/page.tsx81
-rw-r--r--app/[lng]/sales/(sales)/evaluation-target-list/page.tsx115
-rw-r--r--app/[lng]/sales/(sales)/evaluation/page.tsx181
-rw-r--r--app/[lng]/sales/(sales)/faq/manage/actions.ts48
-rw-r--r--app/[lng]/sales/(sales)/faq/manage/page.tsx38
-rw-r--r--app/[lng]/sales/(sales)/faq/page.tsx62
-rw-r--r--app/[lng]/sales/(sales)/form-list/page.tsx75
-rw-r--r--app/[lng]/sales/(sales)/incoterms/page.tsx53
-rw-r--r--app/[lng]/sales/(sales)/items-tech/layout.tsx38
-rw-r--r--app/[lng]/sales/(sales)/items-tech/page.tsx67
-rw-r--r--app/[lng]/sales/(sales)/items/page.tsx68
-rw-r--r--app/[lng]/sales/(sales)/layout.tsx18
-rw-r--r--app/[lng]/sales/(sales)/menu-list/page.tsx70
-rw-r--r--app/[lng]/sales/(sales)/payment-conditions/page.tsx53
-rw-r--r--app/[lng]/sales/(sales)/po-rfq/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/po/page.tsx65
-rw-r--r--app/[lng]/sales/(sales)/poa/page.tsx61
-rw-r--r--app/[lng]/sales/(sales)/pq-criteria/[id]/page.tsx81
-rw-r--r--app/[lng]/sales/(sales)/pq-criteria/page.tsx70
-rw-r--r--app/[lng]/sales/(sales)/pq/[vendorId]/page.tsx108
-rw-r--r--app/[lng]/sales/(sales)/pq/page.tsx71
-rw-r--r--app/[lng]/sales/(sales)/pq_new/[vendorId]/[submissionId]/page.tsx215
-rw-r--r--app/[lng]/sales/(sales)/pq_new/page.tsx96
-rw-r--r--app/[lng]/sales/(sales)/project-gtc/page.tsx63
-rw-r--r--app/[lng]/sales/(sales)/project-vendors/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/projects/page.tsx75
-rw-r--r--app/[lng]/sales/(sales)/report/page.tsx47
-rw-r--r--app/[lng]/sales/(sales)/rfq-tech/[id]/cbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/rfq-tech/[id]/layout.tsx89
-rw-r--r--app/[lng]/sales/(sales)/rfq-tech/[id]/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/rfq-tech/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/rfq-tech/page.tsx76
-rw-r--r--app/[lng]/sales/(sales)/rfq/[id]/cbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/rfq/[id]/layout.tsx89
-rw-r--r--app/[lng]/sales/(sales)/rfq/[id]/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/rfq/[id]/tbe/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/rfq/page.tsx80
-rw-r--r--app/[lng]/sales/(sales)/settings/layout.tsx68
-rw-r--r--app/[lng]/sales/(sales)/settings/page.tsx18
-rw-r--r--app/[lng]/sales/(sales)/settings/preferences/page.tsx17
-rw-r--r--app/[lng]/sales/(sales)/system/admin-users/page.tsx60
-rw-r--r--app/[lng]/sales/(sales)/system/layout.tsx80
-rw-r--r--app/[lng]/sales/(sales)/system/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/system/password-policy/page.tsx63
-rw-r--r--app/[lng]/sales/(sales)/system/permissions/page.tsx17
-rw-r--r--app/[lng]/sales/(sales)/system/roles/page.tsx68
-rw-r--r--app/[lng]/sales/(sales)/tag-numbering/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/tasks/page.tsx63
-rw-r--r--app/[lng]/sales/(sales)/tbe-tech/page.tsx67
-rw-r--r--app/[lng]/sales/(sales)/tbe/page.tsx113
-rw-r--r--app/[lng]/sales/(sales)/tech-project-avl/page.tsx85
-rw-r--r--app/[lng]/sales/(sales)/tech-vendor-candidates/page.tsx78
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/items/page.tsx48
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx82
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/tech-vendors/page.tsx58
-rw-r--r--app/[lng]/sales/(sales)/vendor-candidates/page.tsx78
-rw-r--r--app/[lng]/sales/(sales)/vendor-check-list/page.tsx74
-rw-r--r--app/[lng]/sales/(sales)/vendor-investigation/page.tsx65
-rw-r--r--app/[lng]/sales/(sales)/vendor-type/page.tsx70
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/items/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/layout.tsx94
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/materials/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/page.tsx56
-rw-r--r--app/[lng]/sales/(sales)/vendors/[id]/info/rfq-history/page.tsx55
-rw-r--r--app/[lng]/sales/(sales)/vendors/page.tsx78
96 files changed, 6413 insertions, 0 deletions
diff --git a/app/[lng]/sales/(sales)/b-rfq/[id]/final/page.tsx b/app/[lng]/sales/(sales)/b-rfq/[id]/final/page.tsx
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/app/[lng]/sales/(sales)/b-rfq/[id]/final/page.tsx
diff --git a/app/[lng]/sales/(sales)/b-rfq/[id]/initial/page.tsx b/app/[lng]/sales/(sales)/b-rfq/[id]/initial/page.tsx
new file mode 100644
index 00000000..1af65fbc
--- /dev/null
+++ b/app/[lng]/sales/(sales)/b-rfq/[id]/initial/page.tsx
@@ -0,0 +1,52 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { InitialRfqDetailTable } from "@/lib/b-rfq/initial/initial-rfq-detail-table"
+import { getInitialRfqDetail } from "@/lib/b-rfq/service"
+import { searchParamsInitialRfqDetailCache } from "@/lib/b-rfq/validations"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsInitialRfqDetailCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = getInitialRfqDetail({
+ ...search,
+ filters: validFilters,
+ }, idAsNumber)
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Initial RFQ List
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 설계로부터 받은 RFQ 문서와 구매 RFQ 문서 및 사전 계약자료를 Vendor에 발송하기 위한 RFQ 생성 및 관리하는 화면입니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <InitialRfqDetailTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/b-rfq/[id]/layout.tsx b/app/[lng]/sales/(sales)/b-rfq/[id]/layout.tsx
new file mode 100644
index 00000000..8dad7676
--- /dev/null
+++ b/app/[lng]/sales/(sales)/b-rfq/[id]/layout.tsx
@@ -0,0 +1,87 @@
+import { Metadata } from "next"
+import Link from "next/link"
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+import { RfqDashboardView } from "@/db/schema"
+import { findBRfqById } from "@/lib/b-rfq/service"
+
+export const metadata: Metadata = {
+ title: "견적 RFQ 상세",
+}
+
+export default async function RfqLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string, id: string }
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 협력업체 정보 조회
+ const rfq: RfqDashboardView | null = await findBRfqById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "견적/입찰 문서관리",
+ href: `/${lng}/evcp/b-rfq/${id}`,
+ },
+ {
+ title: "Initial RFQ 발송",
+ href: `/${lng}/evcp/b-rfq/${id}/initial`,
+ },
+ {
+ title: "Final RFQ 발송",
+ href: `/${lng}/evcp/b-rfq/${id}/final`,
+ },
+
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/b-rfq`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>RFQ 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
+ <div className="space-y-0.5">
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {rfq
+ ? `${rfq.rfqCode ?? ""} | ${rfq.packageNo ?? ""} | ${rfq.packageName ?? ""}`
+ : "Loading RFQ..."}
+ </h2>
+
+ <p className="text-muted-foreground">
+ PR발행 전 RFQ를 생성하여 관리하는 화면입니다.
+ </p>
+ <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="lg:w-64 flex-shrink-0">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/b-rfq/[id]/page.tsx b/app/[lng]/sales/(sales)/b-rfq/[id]/page.tsx
new file mode 100644
index 00000000..26dc45fb
--- /dev/null
+++ b/app/[lng]/sales/(sales)/b-rfq/[id]/page.tsx
@@ -0,0 +1,53 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsRfqAttachmentsCache } from "@/lib/b-rfq/validations"
+import { getRfqAttachments } from "@/lib/b-rfq/service"
+import { RfqAttachmentsTable } from "@/lib/b-rfq/attachment/attachment-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsRfqAttachmentsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = getRfqAttachments({
+ ...search,
+ filters: validFilters,
+ }, idAsNumber)
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ 견적 RFQ 문서관리
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 설계로부터 받은 RFQ 문서와 구매 RFQ 문서를 관리하고 Vendor 회신을 점검/관리하는 화면입니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <RfqAttachmentsTable promises={promises} rfqId={idAsNumber} />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/b-rfq/page.tsx b/app/[lng]/sales/(sales)/b-rfq/page.tsx
new file mode 100644
index 00000000..a66d7b58
--- /dev/null
+++ b/app/[lng]/sales/(sales)/b-rfq/page.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+import { Metadata } from "next"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { searchParamsRFQDashboardCache } from "@/lib/b-rfq/validations"
+import { getRFQDashboard } from "@/lib/b-rfq/service"
+import { RFQDashboardTable } from "@/lib/b-rfq/summary-table/summary-rfq-table"
+
+export const metadata: Metadata = {
+ title: "견적 RFQ",
+ description: "",
+}
+
+interface PQReviewPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function PQReviewPage(props: PQReviewPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsRFQDashboardCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // 기본 필터 처리 (통일된 이름 사용)
+ let basicFilters = []
+ if (search.basicFilters && search.basicFilters.length > 0) {
+ basicFilters = search.basicFilters
+ console.log("Using search.basicFilters:", basicFilters);
+ } else {
+ console.log("No basic filters found");
+ }
+
+ // 모든 필터를 합쳐서 처리
+ const allFilters = [...validFilters, ...basicFilters]
+
+ // 조인 연산자도 통일된 이름 사용
+ const joinOperator = search.basicJoinOperator || search.joinOperator || 'and';
+
+ // Promise.all로 감싸서 전달
+ const promises = Promise.all([
+ getRFQDashboard({
+ ...search,
+ filters: allFilters,
+ joinOperator,
+ })
+ ])
+
+ console.log(search, "견적")
+
+ return (
+ <Shell className="gap-4">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 견적 RFQ
+ </h2>
+ </div>
+ </div>
+ </div>
+
+ {/* Items처럼 직접 테이블 렌더링 */}
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RFQDashboardTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/basic-contract-template/page.tsx b/app/[lng]/sales/(sales)/basic-contract-template/page.tsx
new file mode 100644
index 00000000..adc57ed9
--- /dev/null
+++ b/app/[lng]/sales/(sales)/basic-contract-template/page.tsx
@@ -0,0 +1,74 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getBasicContractTemplates } from "@/lib/basic-contract/service"
+import { searchParamsTemplatesCache } from "@/lib/basic-contract/validations"
+import { BasicContractTemplateTable } from "@/lib/basic-contract/template/basic-contract-template"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsTemplatesCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getBasicContractTemplates({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 기본계약서 템플릿 관리
+ </h2>
+ <p className="text-muted-foreground">
+ 기본계약서를 비롯하여 초기 서명이 필요한 문서를 등록하고 편집할 수 있습니다. 활성화된 템플릿이 서명 요청의 리스트에 나타나게 됩니다..{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <BasicContractTemplateTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/basic-contract/page.tsx b/app/[lng]/sales/(sales)/basic-contract/page.tsx
new file mode 100644
index 00000000..a043e530
--- /dev/null
+++ b/app/[lng]/sales/(sales)/basic-contract/page.tsx
@@ -0,0 +1,74 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getBasicContracts } from "@/lib/basic-contract/service"
+import { searchParamsCache } from "@/lib/basic-contract/validations"
+import { BasicContractsTable } from "@/lib/basic-contract/status/basic-contract-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getBasicContracts({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 기본계약서 서명 현황
+ </h2>
+ <p className="text-muted-foreground">
+ 기본계약서를 비롯하여 초기 서명이 필요한 문서의 서명 현황을 확인할 수 있고 서명된 문서들을 다운로드할 수 있습니다. {" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <BasicContractsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/bid-projects/page.tsx b/app/[lng]/sales/(sales)/bid-projects/page.tsx
new file mode 100644
index 00000000..2039e5b2
--- /dev/null
+++ b/app/[lng]/sales/(sales)/bid-projects/page.tsx
@@ -0,0 +1,74 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getBidProjectLists } from "@/lib/bidding-projects/service"
+import { searchParamsBidProjectsCache } from "@/lib/bidding-projects/validation"
+import { BidProjectsTable } from "@/lib/bidding-projects/table/projects-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsBidProjectsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getBidProjectLists({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 견적 프로젝트 리스트
+ </h2>
+ <p className="text-muted-foreground">
+ SAP(S-ERP)로부터 수신한 견적 프로젝트 데이터입니다. 기술영업의 Budgetary RFQ에서 사용됩니다.
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <BidProjectsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/bqcbe/page.tsx b/app/[lng]/sales/(sales)/bqcbe/page.tsx
new file mode 100644
index 00000000..ae503feb
--- /dev/null
+++ b/app/[lng]/sales/(sales)/bqcbe/page.tsx
@@ -0,0 +1,74 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getAllCBE } from "@/lib/rfqs/service"
+import { searchParamsCBECache } from "@/lib/rfqs/validations"
+
+import { AllCbeTable } from "@/lib/cbe/table/cbe-table"
+
+import { RfqType } from "@/lib/rfqs/validations"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ }
+ searchParams: Promise<SearchParams>
+ rfqType: RfqType
+}
+
+export default async function RfqCBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+
+ const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getAllCBE({
+ ...search,
+ filters: validFilters,
+ rfqType
+ }
+ )
+ ])
+
+ // 4) 렌더링
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Commercial Bid Evaluation
+ </h2>
+ <p className="text-muted-foreground">
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <AllCbeTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/bqtbe/page.tsx b/app/[lng]/sales/(sales)/bqtbe/page.tsx
new file mode 100644
index 00000000..4989c235
--- /dev/null
+++ b/app/[lng]/sales/(sales)/bqtbe/page.tsx
@@ -0,0 +1,72 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getAllTBE } from "@/lib/rfqs/service"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { AllTbeTable } from "@/lib/tbe/table/tbe-table"
+import { RfqType } from "@/lib/rfqs/validations"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ }
+ searchParams: Promise<SearchParams>
+ rfqType: RfqType
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+
+ const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getAllTBE({
+ ...search,
+ filters: validFilters,
+ rfqType
+ }
+ )
+ ])
+
+ // 4) 렌더링
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Technical Bid Evaluation
+ </h2>
+ <p className="text-muted-foreground">
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <AllTbeTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx
new file mode 100644
index 00000000..956facd3
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/cbe/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getCBE, getTBE } from "@/lib/rfqs/service"
+import { searchParamsCBECache, } from "@/lib/rfqs/validations"
+import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
+import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getCBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Commercial Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <CbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx
new file mode 100644
index 00000000..ba7c071c
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/layout.tsx
@@ -0,0 +1,90 @@
+import { Metadata } from "next"
+import Link from "next/link"
+import { ArrowLeft } from "lucide-react"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { RfqViewWithItems } from "@/db/schema/rfq"
+import { findRfqById } from "@/lib/rfqs/service"
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+export const metadata: Metadata = {
+ title: "Vendor Detail",
+}
+
+export default async function RfqLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string, id: string }
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 협력업체 정보 조회
+ const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "Matched Vendors",
+ href: `/${lng}/evcp/budgetary/${id}`,
+ },
+ {
+ title: "TBE",
+ href: `/${lng}/evcp/budgetary/${id}/tbe`,
+ },
+ {
+ title: "CBE",
+ href: `/${lng}/evcp/budgetary/${id}/cbe`,
+ },
+
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/budgetary-rfq`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>Budgetary RFQ 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
+ <div className="space-y-0.5">
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {rfq
+ ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
+ : "Loading RFQ..."}
+ </h2>
+
+ <p className="text-muted-foreground">
+ {rfq
+ ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
+ : ""}
+ </p>
+ <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="lg:w-64 flex-shrink-0">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx
new file mode 100644
index 00000000..dd9df563
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/page.tsx
@@ -0,0 +1,57 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getMatchedVendors } from "@/lib/rfqs/service"
+import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
+import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
+import { RfqType } from "@/lib/rfqs/validations"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+ rfqType: RfqType
+}
+
+export default async function RfqPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+ const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ const searchParams = await props.searchParams
+ const search = searchParamsMatchedVCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getMatchedVendors({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Vendors
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx
new file mode 100644
index 00000000..ec894e1c
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary-rfq/[id]/tbe/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getTBE } from "@/lib/rfqs/service"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Technical Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <TbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-rfq/page.tsx b/app/[lng]/sales/(sales)/budgetary-rfq/page.tsx
new file mode 100644
index 00000000..dc2a4a2b
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary-rfq/page.tsx
@@ -0,0 +1,86 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { searchParamsCache } from "@/lib/rfqs/validations"
+import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
+import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
+import { getAllItems } from "@/lib/items/service"
+import { RfqType } from "@/lib/rfqs/validations"
+import { Ellipsis } from "lucide-react"
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>;
+ rfqType: RfqType;
+ title: string;
+ description: string;
+}
+
+export default async function RfqPage({
+ searchParams,
+ rfqType = RfqType.PURCHASE_BUDGETARY,
+ title = "Budgetary Quote",
+ description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다."
+}: RfqPageProps) {
+ const search = searchParamsCache.parse(await searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRfqs({
+ ...search,
+ filters: validFilters,
+ rfqType // 전달받은 rfqType 사용
+ }),
+ getRfqStatusCounts(rfqType), // rfqType 전달
+ getAllItems()
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {title}
+ </h2>
+ <p className="text-muted-foreground">
+ {description}
+ 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후,
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqsTable promises={promises} rfqType={rfqType} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx b/app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx
new file mode 100644
index 00000000..b1be29db
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary-tech-sales-hull/page.tsx
@@ -0,0 +1,61 @@
+import { searchParamsHullCache } from "@/lib/techsales-rfq/validations"
+import { getTechSalesHullRfqsWithJoin } from "@/lib/techsales-rfq/service"
+import { getValidFilters } from "@/lib/data-table"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
+import { type SearchParams } from "@/types/table"
+import * as React from "react"
+
+interface HullRfqPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function HullRfqPage(props: HullRfqPageProps) {
+ // searchParams를 await하여 resolve
+ const searchParams = await props.searchParams
+
+ // 해양 HULL용 파라미터 파싱
+ const search = searchParamsHullCache.parse(searchParams);
+ const validFilters = getValidFilters(search.filters);
+
+ // 기술영업 해양 Hull RFQ 데이터를 Promise.all로 감싸서 전달
+ const promises = Promise.all([
+ getTechSalesHullRfqsWithJoin({
+ ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
+ filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
+ })
+ ])
+
+ return (
+ <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
+ {/* 고정 헤더 영역 */}
+ <div className="flex-shrink-0">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 기술영업-해양 Hull RFQ
+ </h2>
+ </div>
+ </div>
+ </div>
+
+ {/* 테이블 영역 - 남은 공간 모두 차지 */}
+ <div className="flex-1 min-h-0">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RFQListTable promises={promises} className="h-full" rfqType="HULL" />
+ </React.Suspense>
+ </div>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx b/app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx
new file mode 100644
index 00000000..b7bf9d15
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary-tech-sales-ship/page.tsx
@@ -0,0 +1,61 @@
+import { searchParamsShipCache } from "@/lib/techsales-rfq/validations"
+import { getTechSalesShipRfqsWithJoin } from "@/lib/techsales-rfq/service"
+import { getValidFilters } from "@/lib/data-table"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
+import { type SearchParams } from "@/types/table"
+import * as React from "react"
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqPage(props: RfqPageProps) {
+ // searchParams를 await하여 resolve
+ const searchParams = await props.searchParams
+
+ // 조선용 파라미터 파싱
+ const search = searchParamsShipCache.parse(searchParams);
+ const validFilters = getValidFilters(search.filters);
+
+ // 기술영업 조선 RFQ 데이터를 Promise.all로 감싸서 전달
+ const promises = Promise.all([
+ getTechSalesShipRfqsWithJoin({
+ ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
+ filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
+ })
+ ])
+
+ return (
+ <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
+ {/* 고정 헤더 영역 */}
+ <div className="flex-shrink-0">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 기술영업-조선 RFQ
+ </h2>
+ </div>
+ </div>
+ </div>
+
+ {/* 테이블 영역 - 남은 공간 모두 차지 */}
+ <div className="flex-1 min-h-0">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RFQListTable promises={promises} className="h-full" rfqType="SHIP" />
+ </React.Suspense>
+ </div>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx b/app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx
new file mode 100644
index 00000000..f84a9794
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary-tech-sales-top/page.tsx
@@ -0,0 +1,61 @@
+import { searchParamsTopCache } from "@/lib/techsales-rfq/validations"
+import { getTechSalesTopRfqsWithJoin } from "@/lib/techsales-rfq/service"
+import { getValidFilters } from "@/lib/data-table"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { RFQListTable } from "@/lib/techsales-rfq/table/rfq-table"
+import { type SearchParams } from "@/types/table"
+import * as React from "react"
+
+interface HullRfqPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function HullRfqPage(props: HullRfqPageProps) {
+ // searchParams를 await하여 resolve
+ const searchParams = await props.searchParams
+
+ // 해양 TOP용 파라미터 파싱
+ const search = searchParamsTopCache.parse(searchParams);
+ const validFilters = getValidFilters(search.filters);
+
+ // 기술영업 해양 TOP RFQ 데이터를 Promise.all로 감싸서 전달
+ const promises = Promise.all([
+ getTechSalesTopRfqsWithJoin({
+ ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
+ filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
+ })
+ ])
+
+ return (
+ <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
+ {/* 고정 헤더 영역 */}
+ <div className="flex-shrink-0">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 기술영업-해양 TOP RFQ
+ </h2>
+ </div>
+ </div>
+ </div>
+
+ {/* 테이블 영역 - 남은 공간 모두 차지 */}
+ <div className="flex-1 min-h-0">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RFQListTable promises={promises} className="h-full" rfqType="TOP" />
+ </React.Suspense>
+ </div>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx
new file mode 100644
index 00000000..956facd3
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary/[id]/cbe/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getCBE, getTBE } from "@/lib/rfqs/service"
+import { searchParamsCBECache, } from "@/lib/rfqs/validations"
+import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
+import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getCBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Commercial Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <CbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx
new file mode 100644
index 00000000..b0711c66
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary/[id]/layout.tsx
@@ -0,0 +1,90 @@
+import { Metadata } from "next"
+import Link from "next/link"
+import { ArrowLeft } from "lucide-react"
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { RfqViewWithItems } from "@/db/schema/rfq"
+import { findRfqById } from "@/lib/rfqs/service"
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+export const metadata: Metadata = {
+ title: "Vendor Detail",
+}
+
+export default async function RfqLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string, id: string }
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 협력업체 정보 조회
+ const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "Matched Vendors",
+ href: `/${lng}/evcp/budgetary/${id}`,
+ },
+ {
+ title: "TBE",
+ href: `/${lng}/evcp/budgetary/${id}/tbe`,
+ },
+ {
+ title: "CBE",
+ href: `/${lng}/evcp/budgetary/${id}/cbe`,
+ },
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ {/* RFQ 목록으로 돌아가는 링크 추가 */}
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/budgetary`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>Budgetary Quote 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
+
+ <div className="space-y-0.5">
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {rfq
+ ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
+ : "Loading RFQ..."}
+ </h2>
+
+ <p className="text-muted-foreground">
+ {rfq
+ ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
+ : ""}
+ </p>
+ <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="lg:w-64 flex-shrink-0">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/page.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/page.tsx
new file mode 100644
index 00000000..dd9df563
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary/[id]/page.tsx
@@ -0,0 +1,57 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getMatchedVendors } from "@/lib/rfqs/service"
+import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
+import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
+import { RfqType } from "@/lib/rfqs/validations"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+ rfqType: RfqType
+}
+
+export default async function RfqPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+ const rfqType = props.rfqType ?? RfqType.BUDGETARY // rfqType이 없으면 BUDGETARY로 설정
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ const searchParams = await props.searchParams
+ const search = searchParamsMatchedVCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getMatchedVendors({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Vendors
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <MatchedVendorsTable promises={promises} rfqId={idAsNumber} rfqType={rfqType}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx b/app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx
new file mode 100644
index 00000000..ec894e1c
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary/[id]/tbe/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getTBE } from "@/lib/rfqs/service"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Technical Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <TbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/budgetary/page.tsx b/app/[lng]/sales/(sales)/budgetary/page.tsx
new file mode 100644
index 00000000..04550353
--- /dev/null
+++ b/app/[lng]/sales/(sales)/budgetary/page.tsx
@@ -0,0 +1,86 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { searchParamsCache } from "@/lib/rfqs/validations"
+import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
+import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
+import { getAllItems } from "@/lib/items/service"
+import { RfqType } from "@/lib/rfqs/validations"
+import { Ellipsis } from "lucide-react"
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>;
+ rfqType: RfqType;
+ title: string;
+ description: string;
+}
+
+export default async function RfqPage({
+ searchParams,
+ rfqType = RfqType.BUDGETARY,
+ title = "Budgetary Quote",
+ description = "Budgetary Quote를 등록하여 요청 및 응답을 관리할 수 있습니다."
+}: RfqPageProps) {
+ const search = searchParamsCache.parse(await searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRfqs({
+ ...search,
+ filters: validFilters,
+ rfqType // 전달받은 rfqType 사용
+ }),
+ getRfqStatusCounts(rfqType), // rfqType 전달
+ getAllItems()
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {title}
+ </h2>
+ <p className="text-muted-foreground">
+ {description}
+ 기본적인 정보와 RFQ를 위한 아이템 등록 및 첨부를 한 후,
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span> 을 클릭하면 "Proceed"를 통해 상세화면으로 이동하여 진행할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqsTable promises={promises} rfqType={rfqType} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/cbe-tech/page.tsx b/app/[lng]/sales/(sales)/cbe-tech/page.tsx
new file mode 100644
index 00000000..4dadc58f
--- /dev/null
+++ b/app/[lng]/sales/(sales)/cbe-tech/page.tsx
@@ -0,0 +1,67 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getAllCBE } from "@/lib/rfqs-tech/service"
+import { searchParamsCBECache } from "@/lib/rfqs-tech/validations"
+import { AllCbeTable } from "@/lib/cbe-tech/table/cbe-table"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+
+interface IndexPageProps {
+ params: {
+ lng: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqCBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+
+ // URL 쿼리 파라미터에서 타입 추출
+ const searchParams = await props.searchParams
+
+ // SearchParams 파싱 (Zod)
+ const search = searchParamsCBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // 현재 선택된 타입의 데이터 로드
+ const promises = Promise.all([
+ getAllCBE({
+ ...search,
+ filters: validFilters,
+ })
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Commercial Bid Evaluation
+ </h2>
+ <p className="text-muted-foreground">
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br/>
+ 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <AllCbeTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/dashboard/page.tsx b/app/[lng]/sales/(sales)/dashboard/page.tsx
new file mode 100644
index 00000000..1d61dc16
--- /dev/null
+++ b/app/[lng]/sales/(sales)/dashboard/page.tsx
@@ -0,0 +1,17 @@
+// app/invalid-access/page.tsx
+
+export default function InvalidAccessPage() {
+ return (
+ <main style={{ padding: '40px', textAlign: 'center' }}>
+ <h1>부적절한 접근입니다</h1>
+ <p>
+ 협력업체(Vendor)가 EVCP 화면에 접속하거나 <br />
+ SHI 계정이 협력업체 화면에 접속하려고 시도하는 경우입니다.
+ </p>
+ <p>
+ <strong>접근 권한이 없으므로, 다른 화면으로 이동해 주세요.</strong>
+ </p>
+ </main>
+ );
+ }
+ \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/email-template/[name]/page.tsx b/app/[lng]/sales/(sales)/email-template/[name]/page.tsx
new file mode 100644
index 00000000..cccc10fc
--- /dev/null
+++ b/app/[lng]/sales/(sales)/email-template/[name]/page.tsx
@@ -0,0 +1,26 @@
+import { getTemplateAction } from '@/lib/mail/service';
+import MailTemplateEditorClient from '@/components/mail/mail-template-editor-client';
+
+interface EditMailTemplatePageProps {
+ params: {
+ name: string;
+ lng: string;
+ };
+}
+
+export default async function EditMailTemplatePage({ params }: EditMailTemplatePageProps) {
+ const { name: templateName } = await params;
+
+ // 서버에서 초기 템플릿 데이터 가져오기
+ const result = await getTemplateAction(templateName);
+ const initialTemplate = result.success ? result.data : null;
+
+ return (
+ <div className="container mx-auto p-6">
+ <MailTemplateEditorClient
+ templateName={templateName}
+ initialTemplate={initialTemplate}
+ />
+ </div>
+ );
+}
diff --git a/app/[lng]/sales/(sales)/email-template/page.tsx b/app/[lng]/sales/(sales)/email-template/page.tsx
new file mode 100644
index 00000000..1ef3de6c
--- /dev/null
+++ b/app/[lng]/sales/(sales)/email-template/page.tsx
@@ -0,0 +1,19 @@
+import { getTemplatesAction } from '@/lib/mail/service';
+import MailTemplatesClient from '@/components/mail/mail-templates-client';
+
+export default async function MailTemplatesPage() {
+ // 서버에서 초기 데이터 가져오기
+ const result = await getTemplatesAction();
+ const initialData = result.success ? result.data : [];
+
+ return (
+ <div className="container mx-auto p-6">
+ <div className="mb-8">
+ <h1 className="text-3xl font-bold text-gray-900 mb-2">메일 템플릿 관리</h1>
+ <p className="text-gray-600">이메일 템플릿을 관리할 수 있습니다.</p>
+ </div>
+
+ <MailTemplatesClient initialData={initialData} />
+ </div>
+ );
+}
diff --git a/app/[lng]/sales/(sales)/equip-class/page.tsx b/app/[lng]/sales/(sales)/equip-class/page.tsx
new file mode 100644
index 00000000..cfa8f133
--- /dev/null
+++ b/app/[lng]/sales/(sales)/equip-class/page.tsx
@@ -0,0 +1,75 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/equip-class/validation"
+import { FormListsTable } from "@/lib/form-list/table/formLists-table"
+import { getTagClassists } from "@/lib/equip-class/service"
+import { EquipClassTable } from "@/lib/equip-class/table/equipClass-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTagClassists({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 객체 클래스 목록 from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ 객체 클래스 목록을 확인할 수 있습니다.{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <EquipClassTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/esg-check-list/page.tsx b/app/[lng]/sales/(sales)/esg-check-list/page.tsx
new file mode 100644
index 00000000..515751d5
--- /dev/null
+++ b/app/[lng]/sales/(sales)/esg-check-list/page.tsx
@@ -0,0 +1,74 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getEsgEvaluations } from "@/lib/esg-check-list/service"
+import { getEsgEvaluationsSchema } from "@/lib/esg-check-list/validation"
+import { EsgEvaluationsTable } from "@/lib/esg-check-list/table/esg-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = getEsgEvaluationsSchema.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getEsgEvaluations({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ ESG 자가진단표
+ </h2>
+ <p className="text-muted-foreground">
+ 협력업체 평가에 사용되는 ESG 자가진단표를 관리{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <EsgEvaluationsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/evaluation-check-list/page.tsx b/app/[lng]/sales/(sales)/evaluation-check-list/page.tsx
new file mode 100644
index 00000000..a660c492
--- /dev/null
+++ b/app/[lng]/sales/(sales)/evaluation-check-list/page.tsx
@@ -0,0 +1,81 @@
+/* IMPORT */
+import { DataTableSkeleton } from '@/components/data-table/data-table-skeleton';
+import { getRegEvalCriteria } from '@/lib/evaluation-criteria/service';
+import { getValidFilters } from '@/lib/data-table';
+import RegEvalCriteriaTable from '@/lib/evaluation-criteria/table/reg-eval-criteria-table';
+import { searchParamsCache } from '@/lib/evaluation-criteria/validations';
+import { Shell } from '@/components/shell';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Suspense } from 'react';
+import { type SearchParams } from '@/types/table';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface EvaluationCriteriaPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* REGULAR EVALUATION CRITERIA PAGE */
+async function EvaluationCriteriaPage(props: EvaluationCriteriaPageProps) {
+ const searchParams = await props.searchParams;
+ const search = searchParamsCache.parse(searchParams);
+ const validFilters = getValidFilters(search.filters);
+ const promises = Promise.all([
+ getRegEvalCriteria({
+ ...search,
+ filters: validFilters,
+ }),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 협력업체 평가기준표
+ </h2>
+ <p className="text-muted-foreground">
+ 협력업체 평가에 사용되는 평가기준표를 관리{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </Suspense>
+ <Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={11}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RegEvalCriteriaTable promises={promises} />
+ </Suspense>
+ </Shell>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default EvaluationCriteriaPage; \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/evaluation-target-list/page.tsx b/app/[lng]/sales/(sales)/evaluation-target-list/page.tsx
new file mode 100644
index 00000000..088ae75b
--- /dev/null
+++ b/app/[lng]/sales/(sales)/evaluation-target-list/page.tsx
@@ -0,0 +1,115 @@
+import * as React from "react"
+import { Metadata } from "next"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { HelpCircle } from "lucide-react"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+
+import { getDefaultEvaluationYear, searchParamsEvaluationTargetsCache } from "@/lib/evaluation-target-list/validation"
+import { getEvaluationTargets } from "@/lib/evaluation-target-list/service"
+import { EvaluationTargetsTable } from "@/lib/evaluation-target-list/table/evaluation-target-table"
+
+export const metadata: Metadata = {
+ title: "협력업체 평가 대상 확정",
+ description: "협력업체 평가 대상을 확정하고 담당자를 지정합니다.",
+}
+
+interface EvaluationTargetsPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+
+
+export default async function EvaluationTargetsPage(props: EvaluationTargetsPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsEvaluationTargetsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // 기본 필터 처리 (통일된 이름 사용)
+ let basicFilters = []
+ if (search.basicFilters && search.basicFilters.length > 0) {
+ basicFilters = search.basicFilters
+ console.log("Using search.basicFilters:", basicFilters);
+ } else {
+ console.log("No basic filters found");
+ }
+
+ // 모든 필터를 합쳐서 처리
+ const allFilters = [...validFilters, ...basicFilters]
+
+ // 조인 연산자도 통일된 이름 사용
+ const joinOperator = search.basicJoinOperator || search.joinOperator || 'and';
+
+ // 현재 평가년도 (필터에서 가져오거나 기본값 사용)
+ const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear()
+
+ // Promise.all로 감싸서 전달
+ const promises = Promise.all([
+ getEvaluationTargets({
+ ...search,
+ filters: allFilters,
+ joinOperator,
+ })
+ ])
+
+ return (
+ <Shell className="gap-4">
+ {/* 간소화된 헤더 */}
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ 협력업체 평가 대상 확정
+ </h2>
+ <Badge variant="outline" className="text-sm">
+ {currentEvaluationYear}년도
+ </Badge>
+
+ </div>
+ </div>
+ </div>
+
+ {/* 메인 테이블 (통계는 테이블 내부로 이동) */}
+ <React.Suspense
+ key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링
+ fallback={
+ <DataTableSkeleton
+ columnCount={12}
+ searchableColumnCount={2}
+ filterableColumnCount={6}
+ cellWidths={[
+ "3rem", // checkbox
+ "5rem", // 평가년도
+ "4rem", // 구분
+ "8rem", // 벤더코드
+ "12rem", // 벤더명
+ "4rem", // 내외자
+ "6rem", // 자재구분
+ "5rem", // 상태
+ "5rem", // 의견일치
+ "8rem", // 담당자현황
+ "10rem", // 관리자의견
+ "8rem" // actions
+ ]}
+ shrinkZero
+ />
+ }
+ >
+ {currentEvaluationYear &&
+ <EvaluationTargetsTable
+ promises={promises}
+ evaluationYear={currentEvaluationYear}
+ />
+}
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/evaluation/page.tsx b/app/[lng]/sales/(sales)/evaluation/page.tsx
new file mode 100644
index 00000000..ead61077
--- /dev/null
+++ b/app/[lng]/sales/(sales)/evaluation/page.tsx
@@ -0,0 +1,181 @@
+// ================================================================
+// 4. PERIODIC EVALUATIONS PAGE
+// ================================================================
+
+import * as React from "react"
+import { Metadata } from "next"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { HelpCircle } from "lucide-react"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { PeriodicEvaluationsTable } from "@/lib/evaluation/table/evaluation-table"
+import { getPeriodicEvaluations } from "@/lib/evaluation/service"
+import { searchParamsEvaluationsCache } from "@/lib/evaluation/validation"
+
+export const metadata: Metadata = {
+ title: "협력업체 정기평가",
+ description: "협력업체 정기평가 진행 현황을 관리합니다.",
+}
+
+interface PeriodicEvaluationsPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+// 프로세스 안내 팝오버 컴포넌트
+function ProcessGuidePopover() {
+ return (
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button variant="ghost" size="icon" className="h-6 w-6">
+ <HelpCircle className="h-4 w-4 text-muted-foreground" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-96" align="start">
+ <div className="space-y-3">
+ <div className="space-y-1">
+ <h4 className="font-medium">정기평가 프로세스</h4>
+ <p className="text-sm text-muted-foreground">
+ 확정된 평가 대상 업체들에 대한 정기평가 절차입니다.
+ </p>
+ </div>
+ <div className="space-y-3 text-sm">
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 1
+ </div>
+ <div>
+ <p className="font-medium">평가 대상 확정</p>
+ <p className="text-muted-foreground">평가 대상으로 확정된 업체들의 정기평가가 자동 생성됩니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 2
+ </div>
+ <div>
+ <p className="font-medium">업체 자료 제출</p>
+ <p className="text-muted-foreground">각 업체는 평가에 필요한 자료를 제출 마감일까지 제출해야 합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 3
+ </div>
+ <div>
+ <p className="font-medium">평가자 검토</p>
+ <p className="text-muted-foreground">지정된 평가자들이 평가표를 기반으로 점수를 매기고 검토합니다.</p>
+ </div>
+ </div>
+ <div className="flex gap-3">
+ <div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-xs font-medium text-blue-600">
+ 4
+ </div>
+ <div>
+ <p className="font-medium">최종 확정</p>
+ <p className="text-muted-foreground">모든 평가가 완료되면 최종 점수와 등급이 확정됩니다.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ )
+}
+
+// TODO: 이 함수들은 실제 서비스 파일에서 구현해야 함
+function getDefaultEvaluationYear() {
+ return new Date().getFullYear()
+}
+
+
+
+export default async function PeriodicEvaluationsPage(props: PeriodicEvaluationsPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsEvaluationsCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters || [])
+
+ // 기본 필터 처리
+ let basicFilters = []
+ if (search.basicFilters && search.basicFilters.length > 0) {
+ basicFilters = search.basicFilters
+ }
+
+ // 모든 필터를 합쳐서 처리
+ const allFilters = [...validFilters, ...basicFilters]
+
+ // 조인 연산자
+ const joinOperator = search.basicJoinOperator || search.joinOperator || 'and';
+
+ // 현재 평가년도
+ const currentEvaluationYear = search.evaluationYear || getDefaultEvaluationYear()
+
+ // Promise.all로 감싸서 전달
+ const promises = Promise.all([
+ getPeriodicEvaluations({
+ ...search,
+ filters: allFilters,
+ joinOperator,
+ })
+ ])
+
+ return (
+ <Shell className="gap-4">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center gap-2">
+ <h2 className="text-2xl font-bold tracking-tight">
+ 협력업체 정기평가
+ </h2>
+ <Badge variant="outline" className="text-sm">
+ {currentEvaluationYear}년도
+ </Badge>
+ </div>
+ </div>
+ </div>
+
+ {/* 메인 테이블 */}
+ <React.Suspense
+ key={JSON.stringify(searchParams)}
+ fallback={
+ <DataTableSkeleton
+ columnCount={15}
+ searchableColumnCount={2}
+ filterableColumnCount={8}
+ cellWidths={[
+ "3rem", // checkbox
+ "5rem", // 평가년도
+ "5rem", // 평가기간
+ "4rem", // 구분
+ "8rem", // 벤더코드
+ "12rem", // 벤더명
+ "4rem", // 내외자
+ "6rem", // 자재구분
+ "5rem", // 문서제출
+ "4rem", // 제출일
+ "4rem", // 마감일
+ "4rem", // 총점
+ "4rem", // 등급
+ "5rem", // 진행상태
+ "8rem" // actions
+ ]}
+ shrinkZero
+ />
+ }
+ >
+ <PeriodicEvaluationsTable
+ promises={promises}
+ evaluationYear={currentEvaluationYear}
+ />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/faq/manage/actions.ts b/app/[lng]/sales/(sales)/faq/manage/actions.ts
new file mode 100644
index 00000000..bc443a8a
--- /dev/null
+++ b/app/[lng]/sales/(sales)/faq/manage/actions.ts
@@ -0,0 +1,48 @@
+'use server';
+
+import { promises as fs } from 'fs';
+import path from 'path';
+import { FaqCategory } from '@/components/faq/FaqCard';
+import { fallbackLng } from '@/i18n/settings';
+
+const FAQ_CONFIG_PATH = path.join(process.cwd(), 'config', 'faqDataConfig.ts');
+
+export async function updateFaqData(lng: string, newData: FaqCategory[]) {
+ try {
+ const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
+ const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
+ if (!dataMatch) {
+ throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
+ }
+
+ const allData = eval(`(${dataMatch[1]})`);
+ const updatedData = {
+ ...allData,
+ [lng]: newData
+ };
+
+ const newFileContent = `import { FaqCategory } from "@/components/faq/FaqCard";\n\ninterface LocalizedFaqCategories {\n [lng: string]: FaqCategory[];\n}\n\nexport const faqCategories: LocalizedFaqCategories = ${JSON.stringify(updatedData, null, 4)};`;
+ await fs.writeFile(FAQ_CONFIG_PATH, newFileContent, 'utf-8');
+
+ return { success: true };
+ } catch (error) {
+ console.error('FAQ 데이터 업데이트 중 오류 발생:', error);
+ return { success: false, error: '데이터 업데이트 중 오류가 발생했습니다.' };
+ }
+}
+
+export async function getFaqData(lng: string): Promise<{ data: FaqCategory[] }> {
+ try {
+ const fileContent = await fs.readFile(FAQ_CONFIG_PATH, 'utf-8');
+ const dataMatch = fileContent.match(/export const faqCategories[^=]*=\s*(\{[\s\S]*\});/);
+ if (!dataMatch) {
+ throw new Error('FAQ 데이터 형식이 올바르지 않습니다.');
+ }
+
+ const allData = eval(`(${dataMatch[1]})`);
+ return { data: allData[lng] || allData[fallbackLng] || [] };
+ } catch (error) {
+ console.error('FAQ 데이터 읽기 중 오류 발생:', error);
+ return { data: [] };
+ }
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/faq/manage/page.tsx b/app/[lng]/sales/(sales)/faq/manage/page.tsx
new file mode 100644
index 00000000..011bbfa4
--- /dev/null
+++ b/app/[lng]/sales/(sales)/faq/manage/page.tsx
@@ -0,0 +1,38 @@
+import { FaqManager } from '@/components/faq/FaqManager';
+import { getFaqData, updateFaqData } from './actions';
+import { revalidatePath } from 'next/cache';
+import { FaqCategory } from '@/components/faq/FaqCard';
+
+interface Props {
+ params: {
+ lng: string;
+ }
+}
+
+export default async function FaqManagePage(props: Props) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const { data } = await getFaqData(lng);
+
+ async function handleSave(newData: FaqCategory[]) {
+ 'use server';
+ await updateFaqData(lng, newData);
+ revalidatePath(`/${lng}/evcp/faq`);
+ }
+
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="space-y-6 p-10 pb-16">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">FAQ Management</h2>
+ <p className="text-muted-foreground">
+ Manage FAQ categories and items for {lng.toUpperCase()} language.
+ </p>
+ </div>
+ <FaqManager initialData={data} onSave={handleSave} lng={lng} />
+ </div>
+ </section>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/faq/page.tsx b/app/[lng]/sales/(sales)/faq/page.tsx
new file mode 100644
index 00000000..9b62b7e4
--- /dev/null
+++ b/app/[lng]/sales/(sales)/faq/page.tsx
@@ -0,0 +1,62 @@
+import { Separator } from "@/components/ui/separator"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { faqCategories } from "@/config/faqDataConfig"
+import { FaqCard } from "@/components/faq/FaqCard"
+import { Button } from "@/components/ui/button"
+import { Settings } from "lucide-react"
+import Link from "next/link"
+import { fallbackLng } from "@/i18n/settings"
+
+interface Props {
+ params: {
+ lng: string;
+ }
+}
+
+export default async function FaqPage(props: Props) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const localizedFaqCategories = faqCategories[lng] || faqCategories[fallbackLng];
+
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="space-y-6 p-10 pb-16">
+ <div className="flex justify-between items-center">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">Frequently Asked Questions</h2>
+ <p className="text-muted-foreground">
+ Find answers to common questions about using the EVCP system.
+ </p>
+ </div>
+ <Link href={`/${lng}/evcp/faq/manage`}>
+ <Button variant="outline">
+ <Settings className="w-4 h-4 mr-2" />
+ Manage FAQ
+ </Button>
+ </Link>
+ </div>
+ <Separator className="my-6" />
+
+ <Tabs defaultValue={localizedFaqCategories[0]?.label} className="space-y-4">
+ <TabsList>
+ {localizedFaqCategories.map((category) => (
+ <TabsTrigger key={category.label} value={category.label}>
+ {category.label}
+ </TabsTrigger>
+ ))}
+ </TabsList>
+
+ {localizedFaqCategories.map((category) => (
+ <TabsContent key={category.label} value={category.label} className="space-y-4">
+ {category.items.map((item, index) => (
+ <FaqCard key={index} item={item} />
+ ))}
+ </TabsContent>
+ ))}
+ </Tabs>
+ </div>
+ </section>
+ </div>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/form-list/page.tsx b/app/[lng]/sales/(sales)/form-list/page.tsx
new file mode 100644
index 00000000..a6cf7d9e
--- /dev/null
+++ b/app/[lng]/sales/(sales)/form-list/page.tsx
@@ -0,0 +1,75 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/form-list/validation"
+import { ItemsTable } from "@/lib/items/table/items-table"
+import { getFormLists } from "@/lib/form-list/service"
+import { FormListsTable } from "@/lib/form-list/table/formLists-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getFormLists({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 레지스터 목록 from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ 협력업체 데이터 입력을 위한 레지스터 목록 리스트입니다.{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <FormListsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/incoterms/page.tsx b/app/[lng]/sales/(sales)/incoterms/page.tsx
new file mode 100644
index 00000000..57a19009
--- /dev/null
+++ b/app/[lng]/sales/(sales)/incoterms/page.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import { type SearchParams } from "@/types/table";
+import { getValidFilters } from "@/lib/data-table";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { SearchParamsCache } from "@/lib/incoterms/validations";
+import { getIncoterms } from "@/lib/incoterms/service";
+import { IncotermsTable } from "@/lib/incoterms/table/incoterms-table";
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+ const search = SearchParamsCache.parse(searchParams);
+ const validFilters = getValidFilters(search.filters);
+
+ const promises = Promise.all([
+ getIncoterms({
+ ...search,
+ filters: validFilters,
+ }),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">인코텀즈 관리</h2>
+ <p className="text-muted-foreground">
+ 인코텀즈(Incoterms)를 등록, 수정, 삭제할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={4}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <IncotermsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/items-tech/layout.tsx b/app/[lng]/sales/(sales)/items-tech/layout.tsx
new file mode 100644
index 00000000..d375059b
--- /dev/null
+++ b/app/[lng]/sales/(sales)/items-tech/layout.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+import { ItemTechContainer } from "@/components/items-tech/item-tech-container"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+
+// Layout 컴포넌트는 서버 컴포넌트입니다
+export default function ItemsShipLayout({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ // 아이템 타입 정의
+ const itemTypes = [
+ { id: "ship", name: "조선 아이템" },
+ { id: "top", name: "해양 TOP" },
+ { id: "hull", name: "해양 HULL" },
+ ]
+
+ return (
+ <Shell className="gap-4">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ItemTechContainer itemTypes={itemTypes}>
+ {children}
+ </ItemTechContainer>
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/items-tech/page.tsx b/app/[lng]/sales/(sales)/items-tech/page.tsx
new file mode 100644
index 00000000..55ac9c63
--- /dev/null
+++ b/app/[lng]/sales/(sales)/items-tech/page.tsx
@@ -0,0 +1,67 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { shipbuildingSearchParamsCache, offshoreTopSearchParamsCache, offshoreHullSearchParamsCache } from "@/lib/items-tech/validations"
+import { getShipbuildingItems, getOffshoreTopItems, getOffshoreHullItems } from "@/lib/items-tech/service"
+import { OffshoreTopTable } from "@/lib/items-tech/table/top/offshore-top-table"
+import { OffshoreHullTable } from "@/lib/items-tech/table/hull/offshore-hull-table"
+
+// 대소문자 문제 해결 - 실제 파일명에 맞게 import
+import { ItemsShipTable } from "@/lib/items-tech/table/ship/Items-ship-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage({ searchParams }: IndexPageProps) {
+ const params = await searchParams
+ const shipbuildingSearch = shipbuildingSearchParamsCache.parse(params)
+ const offshoreTopSearch = offshoreTopSearchParamsCache.parse(params)
+ const offshoreHullSearch = offshoreHullSearchParamsCache.parse(params)
+ const validShipbuildingFilters = getValidFilters(shipbuildingSearch.filters || [])
+ const validOffshoreTopFilters = getValidFilters(offshoreTopSearch.filters || [])
+ const validOffshoreHullFilters = getValidFilters(offshoreHullSearch.filters || [])
+
+
+ // URL에서 아이템 타입 가져오기
+ const itemType = params.type || "ship"
+
+ return (
+ <div>
+ {itemType === "ship" && (
+ <ItemsShipTable
+ promises={Promise.all([
+ getShipbuildingItems({
+ ...shipbuildingSearch,
+ filters: validShipbuildingFilters,
+ }),
+ ]).then(([result]) => result)}
+ />
+ )}
+
+ {itemType === "top" && (
+ <OffshoreTopTable
+ promises={Promise.all([
+ getOffshoreTopItems({
+ ...offshoreTopSearch,
+ filters: validOffshoreTopFilters,
+ }),
+ ]).then(([result]) => result)}
+ />
+ )}
+
+ {itemType === "hull" && (
+ <OffshoreHullTable
+ promises={Promise.all([
+ getOffshoreHullItems({
+ ...offshoreHullSearch,
+ filters: validOffshoreHullFilters,
+ }),
+ ]).then(([result]) => result)}
+ />
+ )}
+ </div>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/items/page.tsx b/app/[lng]/sales/(sales)/items/page.tsx
new file mode 100644
index 00000000..0c44bf0a
--- /dev/null
+++ b/app/[lng]/sales/(sales)/items/page.tsx
@@ -0,0 +1,68 @@
+// app/items/page.tsx (업데이트)
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/items/validations"
+import { getItems } from "@/lib/items/service"
+import { ItemsTable } from "@/lib/items/table/items-table"
+import { ViewModeToggle } from "@/components/data-table/view-mode-toggle"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ // pageSize 기반으로 모드 자동 결정
+ const isInfiniteMode = search.perPage >= 1_000_000
+
+ // 페이지네이션 모드일 때만 서버에서 데이터 가져오기
+ // 무한 스크롤 모드에서는 클라이언트에서 SWR로 데이터 로드
+ const promises = isInfiniteMode
+ ? undefined
+ : Promise.all([
+ getItems(search), // searchParamsCache의 결과를 그대로 사용
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 패키지 정보
+ </h2>
+ <p className="text-muted-foreground">
+ S-EDP로부터 수신된 패키지 정보이며 PR 전 입찰, 견적에 사용되며 벤더 데이터, 문서와 연결됩니다.
+ </p>
+ </div>
+ </div>
+
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* DateRangePicker 등 추가 컴포넌트 */}
+ </React.Suspense>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ {/* 통합된 ItemsTable 컴포넌트 사용 */}
+ <ItemsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/layout.tsx b/app/[lng]/sales/(sales)/layout.tsx
new file mode 100644
index 00000000..82b53307
--- /dev/null
+++ b/app/[lng]/sales/(sales)/layout.tsx
@@ -0,0 +1,18 @@
+import { ReactNode } from 'react';
+import { Header } from '@/components/layout/Header';
+import { SiteFooter } from '@/components/layout/Footer';
+
+export default function EvcpLayout({ children }: { children: ReactNode }) {
+ return (
+ <div className="relative flex min-h-svh flex-col bg-background">
+ {/* <div className="relative flex min-h-svh flex-col bg-slate-100 "> */}
+ <Header />
+ <main className="flex flex-1 flex-col">
+ <div className='container-wrapper'>
+ {children}
+ </div>
+ </main>
+ <SiteFooter/>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/menu-list/page.tsx b/app/[lng]/sales/(sales)/menu-list/page.tsx
new file mode 100644
index 00000000..84138320
--- /dev/null
+++ b/app/[lng]/sales/(sales)/menu-list/page.tsx
@@ -0,0 +1,70 @@
+// app/evcp/menu-list/page.tsx
+
+import { Suspense } from "react";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { RefreshCw, Settings } from "lucide-react";
+import { getActiveUsers, getMenuAssignments } from "@/lib/menu-list/servcie";
+import { InitializeButton } from "@/lib/menu-list/table/initialize-button";
+import { MenuListTable } from "@/lib/menu-list/table/menu-list-table";
+import { Shell } from "@/components/shell"
+import * as React from "react"
+
+export default async function MenuListPage() {
+ // 초기 데이터 로드
+ const [menusResult, usersResult] = await Promise.all([
+ getMenuAssignments(),
+ getActiveUsers()
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 메뉴 관리
+ </h2>
+ <p className="text-muted-foreground">
+ 각 메뉴별로 담당자를 지정하고 관리할 수 있습니다.
+ </p>
+ </div>
+ </div>
+
+ </div>
+
+
+ <React.Suspense
+ fallback={
+ ""
+ }
+ >
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Settings className="h-5 w-5" />
+ 메뉴 리스트
+ </CardTitle>
+ <CardDescription>
+ 시스템의 모든 메뉴와 담당자 정보를 확인할 수 있습니다.
+ {menusResult.data?.length > 0 && (
+ <span className="ml-2 text-sm">
+ 총 {menusResult.data.length}개의 메뉴
+ </span>
+ )}
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
+ <MenuListTable
+ initialMenus={menusResult.data || []}
+ initialUsers={usersResult.data || []}
+ />
+ </Suspense>
+ </CardContent>
+ </Card>
+ </React.Suspense>
+ </Shell>
+
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/payment-conditions/page.tsx b/app/[lng]/sales/(sales)/payment-conditions/page.tsx
new file mode 100644
index 00000000..b9aedfbb
--- /dev/null
+++ b/app/[lng]/sales/(sales)/payment-conditions/page.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import { type SearchParams } from "@/types/table";
+import { getValidFilters } from "@/lib/data-table";
+import { Shell } from "@/components/shell";
+import { Skeleton } from "@/components/ui/skeleton";
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton";
+import { SearchParamsCache } from "@/lib/payment-terms/validations";
+import { getPaymentTerms } from "@/lib/payment-terms/service";
+import { PaymentTermsTable } from "@/lib/payment-terms/table/payment-terms-table";
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>;
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams;
+ const search = SearchParamsCache.parse(searchParams);
+ const validFilters = getValidFilters(search.filters);
+
+ const promises = Promise.all([
+ getPaymentTerms({
+ ...search,
+ filters: validFilters,
+ }),
+ ]);
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">결제 조건 관리</h2>
+ <p className="text-muted-foreground">
+ 결제 조건(Payment Terms)을 등록, 수정, 삭제할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}></React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={4}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PaymentTermsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ );
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/po-rfq/page.tsx b/app/[lng]/sales/(sales)/po-rfq/page.tsx
new file mode 100644
index 00000000..bdeae25e
--- /dev/null
+++ b/app/[lng]/sales/(sales)/po-rfq/page.tsx
@@ -0,0 +1,61 @@
+import { getPORfqs } from "@/lib/procurement-rfqs/services"
+import { searchParamsCache } from "@/lib/procurement-rfqs/validations"
+import { getValidFilters } from "@/lib/data-table"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { RFQListTable } from "@/lib/procurement-rfqs/table/rfq-table"
+import { type SearchParams } from "@/types/table"
+import * as React from "react"
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqPage(props: RfqPageProps) {
+ // searchParams를 await하여 resolve
+ const searchParams = await props.searchParams
+
+ // 파라미터 파싱
+ const search = searchParamsCache.parse(searchParams);
+ const validFilters = getValidFilters(search.filters);
+
+ // RFQ 서버는 기본필터와 고급필터를 분리해서 받으므로 그대로 전달
+ const promises = Promise.all([
+ getPORfqs({
+ ...search, // 모든 파라미터 전달 (page, perPage, sort, basicFilters, filters 등)
+ filters: validFilters, // 고급 필터를 명시적으로 오버라이드 (파싱된 버전)
+ })
+ ])
+
+ return (
+ <Shell variant="fullscreen" className="h-full"> {/* fullscreen variant 사용 */}
+ {/* 고정 헤더 영역 */}
+ <div className="flex-shrink-0">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 발주용 견적
+ </h2>
+ </div>
+ </div>
+ </div>
+
+ {/* 테이블 영역 - 남은 공간 모두 차지 */}
+ <div className="flex-1 min-h-0">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RFQListTable promises={promises} className="h-full" />
+ </React.Suspense>
+ </div>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/po/page.tsx b/app/[lng]/sales/(sales)/po/page.tsx
new file mode 100644
index 00000000..7868e231
--- /dev/null
+++ b/app/[lng]/sales/(sales)/po/page.tsx
@@ -0,0 +1,65 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getPOs } from "@/lib/po/service"
+import { searchParamsCache } from "@/lib/po/validations"
+import { PoListsTable } from "@/lib/po/table/po-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getPOs({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ PO 확인 및 전자서명
+ </h2>
+ <p className="text-muted-foreground">
+ 기간계 시스템으로부터 PO를 확인하고 협력업체에게 전자서명을 요청할 수 있습니다. 요쳥된 전자서명의 이력 또한 확인할 수 있습니다.
+
+ </p>
+ </div>
+ </div>
+ </div>
+
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PoListsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/poa/page.tsx b/app/[lng]/sales/(sales)/poa/page.tsx
new file mode 100644
index 00000000..dec5e05b
--- /dev/null
+++ b/app/[lng]/sales/(sales)/poa/page.tsx
@@ -0,0 +1,61 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getChangeOrders } from "@/lib/poa/service"
+import { searchParamsCache } from "@/lib/poa/validations"
+import { ChangeOrderListsTable } from "@/lib/poa/table/poa-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getChangeOrders({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 변경 PO 확인 및 전자서명
+ </h2>
+ <p className="text-muted-foreground">
+ 발행된 PO의 변경 내역을 확인하고 관리할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ChangeOrderListsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/pq-criteria/[id]/page.tsx b/app/[lng]/sales/(sales)/pq-criteria/[id]/page.tsx
new file mode 100644
index 00000000..55b1e9df
--- /dev/null
+++ b/app/[lng]/sales/(sales)/pq-criteria/[id]/page.tsx
@@ -0,0 +1,81 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/pq/validations"
+import { getPQs } from "@/lib/pq/service"
+import { PqsTable } from "@/lib/pq/table/pq-table"
+import { ProjectSelectorWrapper } from "@/components/pq/project-select-wrapper"
+import { notFound } from "next/navigation"
+
+interface ProjectPageProps {
+ params: { id: string }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function ProjectPage(props: ProjectPageProps) {
+ const resolvedParams = await props.params
+ const id = resolvedParams.id
+
+ const projectId = parseInt(id, 10)
+
+ // 유효하지 않은 projectId 확인
+ if (isNaN(projectId)) {
+ notFound()
+ }
+
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ // filters가 없는 경우를 처리
+ const validFilters = getValidFilters(search.filters)
+
+ // 프로젝트별 PQ 데이터 가져오기
+ const promises = Promise.all([
+ getPQs({
+ ...search,
+ filters: validFilters,
+ }, projectId, false)
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Pre-Qualification Check Sheet
+ </h2>
+ <p className="text-muted-foreground">
+ 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을: 프로젝트별로 관리할 수 있습니다.
+ </p>
+ </div>
+ <ProjectSelectorWrapper selectedProjectId={projectId} />
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PqsTable promises={promises} currentProjectId={projectId}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/pq-criteria/page.tsx b/app/[lng]/sales/(sales)/pq-criteria/page.tsx
new file mode 100644
index 00000000..7785b541
--- /dev/null
+++ b/app/[lng]/sales/(sales)/pq-criteria/page.tsx
@@ -0,0 +1,70 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/pq/validations"
+import { getPQs } from "@/lib/pq/service"
+import { PqsTable } from "@/lib/pq/table/pq-table"
+import { ProjectSelectorWrapper } from "@/components/pq/project-select-wrapper"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ // filters가 없는 경우를 처리
+
+ const validFilters = getValidFilters(search.filters)
+
+ // onlyGeneral: true로 설정하여 일반 PQ 항목만 가져옴
+ const promises = Promise.all([
+ getPQs({
+ ...search,
+ filters: validFilters,
+ }, null, true)
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Pre-Qualification Check Sheet
+ </h2>
+ <p className="text-muted-foreground">
+ 협력업체 등록을 위한, 협력업체가 제출할 PQ 항목을 관리할 수 있습니다.
+ </p>
+ </div>
+ <ProjectSelectorWrapper />
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PqsTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/pq/[vendorId]/page.tsx b/app/[lng]/sales/(sales)/pq/[vendorId]/page.tsx
new file mode 100644
index 00000000..76bcfe59
--- /dev/null
+++ b/app/[lng]/sales/(sales)/pq/[vendorId]/page.tsx
@@ -0,0 +1,108 @@
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { type SearchParams } from "@/types/table"
+import { getPQDataByVendorId, getVendorPQsList, loadGeneralPQData, loadProjectPQAction, loadProjectPQData } from "@/lib/pq/service"
+import { Vendor } from "@/db/schema/vendors"
+import { findVendorById } from "@/lib/vendors/service"
+import VendorPQAdminReview from "@/components/pq/pq-review-detail"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Badge } from "@/components/ui/badge"
+
+interface IndexPageProps {
+ params: {
+ vendorId: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function PQReviewPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const vendorId = Number(resolvedParams.vendorId)
+
+ // Fetch the vendor data
+ const vendor: Vendor | null = await findVendorById(vendorId)
+ if (!vendor) return <div>Vendor not found</div>
+
+ // Get list of all PQs (general + project-specific) for this vendor
+ const pqsList = await getVendorPQsList(vendorId)
+
+ // Determine default active PQ to display
+ // If query param projectId exists, use that, otherwise use general PQ if available
+ const searchParams = await props.searchParams
+ const activeProjectId = searchParams.projectId ? Number(searchParams.projectId) : undefined
+
+ // If no projectId query param, default to general PQ or first project PQ
+ const defaultTabId = activeProjectId ?
+ `project-${activeProjectId}` :
+ (pqsList.hasGeneralPq ? 'general' : `project-${pqsList.projectPQs[0]?.projectId}`)
+
+ // Fetch PQ data for the active tab
+ let pqData;
+ if (activeProjectId) {
+ // Get project-specific PQ data
+ pqData = await getPQDataByVendorId(vendorId, activeProjectId)
+ } else {
+ // Get general PQ data
+ pqData = await getPQDataByVendorId(vendorId)
+ }
+
+ return (
+ <Shell className="gap-2">
+ {pqsList.hasGeneralPq || pqsList.projectPQs.length > 0 ? (
+ <Tabs defaultValue={defaultTabId} className="space-y-4">
+ <div className="flex justify-between items-center">
+ <h1 className="text-2xl font-bold">
+ {vendor.vendorName} PQ Review
+ </h1>
+
+ <TabsList>
+ {pqsList.hasGeneralPq && (
+ <TabsTrigger value="general">
+ General PQ <Badge variant="outline" className="ml-2">Standard</Badge>
+ </TabsTrigger>
+ )}
+
+ {pqsList.projectPQs.map((project) => (
+ <TabsTrigger key={project.projectId} value={`project-${project.projectId}`}>
+ {project.projectName} <Badge variant="outline" className="ml-2">{project.status}</Badge>
+ </TabsTrigger>
+ ))}
+ </TabsList>
+ </div>
+
+ {/* Tab content for General PQ */}
+ {pqsList.hasGeneralPq && (
+ <TabsContent value="general" className="mt-0">
+ <VendorPQAdminReview
+ data={activeProjectId ? [] : pqData}
+ vendor={vendor}
+ projectId={undefined}
+ loadData={loadGeneralPQData}
+ pqType="general"
+ />
+ </TabsContent>
+ )}
+
+ {/* Tab content for each Project PQ */}
+ {pqsList.projectPQs.map((project) => (
+ <TabsContent key={project.projectId} value={`project-${project.projectId}`} className="mt-0">
+ <VendorPQAdminReview
+ data={activeProjectId === project.projectId ? pqData : []}
+ vendor={vendor}
+ projectId={project.projectId}
+ projectName={project.projectName}
+ projectStatus={project.status}
+ loadData={loadProjectPQAction}
+ pqType="project"
+ />
+ </TabsContent>
+ ))}
+ </Tabs>
+ ) : (
+ <div className="text-center py-10">
+ <h2 className="text-xl font-medium">No PQ submissions found for this vendor</h2>
+ </div>
+ )}
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/pq/page.tsx b/app/[lng]/sales/(sales)/pq/page.tsx
new file mode 100644
index 00000000..46b22b12
--- /dev/null
+++ b/app/[lng]/sales/(sales)/pq/page.tsx
@@ -0,0 +1,71 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { getVendorsInPQ } from "@/lib/pq/service"
+import { searchParamsCache } from "@/lib/vendors/validations"
+import { VendorsPQReviewTable } from "@/lib/pq/pq-review-table/vendors-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendorsInPQ({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Pre-Qualification Review
+ </h2>
+ <p className="text-muted-foreground">
+ 벤더가 제출한 PQ를 확인하고 수정 요청 등을 할 수 있으며 PQ 종료 후에는 통과 여부를 결정할 수 있습니다.
+
+ </p>
+ </div>
+ </div>
+ </div>
+
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <VendorsPQReviewTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/pq_new/[vendorId]/[submissionId]/page.tsx b/app/[lng]/sales/(sales)/pq_new/[vendorId]/[submissionId]/page.tsx
new file mode 100644
index 00000000..28ce3128
--- /dev/null
+++ b/app/[lng]/sales/(sales)/pq_new/[vendorId]/[submissionId]/page.tsx
@@ -0,0 +1,215 @@
+import * as React from "react"
+import { Metadata } from "next"
+import Link from "next/link"
+import { notFound } from "next/navigation"
+import { ArrowLeft } from "lucide-react"
+import { Shell } from "@/components/shell"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Separator } from "@/components/ui/separator"
+import { getPQById, getPQDataByVendorId } from "@/lib/pq/service"
+import { unstable_noStore as noStore } from 'next/cache'
+import { PQReviewWrapper } from "@/components/pq-input/pq-review-wrapper"
+
+export const metadata: Metadata = {
+ title: "PQ 검토",
+ description: "협력업체의 Pre-Qualification 답변을 검토합니다.",
+}
+
+// 페이지가 기본적으로 동적임을 나타냄
+export const dynamic = "force-dynamic"
+
+interface PQReviewPageProps {
+ params: Promise<{
+ vendorId: string;
+ submissionId: string;
+ }>
+}
+
+export default async function PQReviewPage(props: PQReviewPageProps) {
+ // 캐시 비활성화
+ noStore()
+
+ const params = await props.params
+ const vendorId = parseInt(params.vendorId, 10)
+ const submissionId = parseInt(params.submissionId, 10)
+
+ try {
+ // PQ Submission 정보 조회
+ const pqSubmission = await getPQById(submissionId, vendorId)
+
+ // PQ 데이터 조회 (질문과 답변)
+ const pqData = await getPQDataByVendorId(vendorId, pqSubmission.projectId || undefined)
+
+ // 프로젝트 정보 (프로젝트 PQ인 경우)
+ const projectInfo = pqSubmission.projectId ? {
+ id: pqSubmission.projectId,
+ projectCode: pqSubmission.projectCode || '',
+ projectName: pqSubmission.projectName || '',
+ status: pqSubmission.status,
+ submittedAt: pqSubmission.submittedAt,
+ } : null
+
+ // PQ 유형 및 상태 레이블
+ const typeLabel = pqSubmission.type === "GENERAL" ? "일반 PQ" : "프로젝트 PQ"
+ const statusLabel = getStatusLabel(pqSubmission.status)
+ const statusVariant = getStatusVariant(pqSubmission.status)
+
+ // 수정 가능 여부 (SUBMITTED 상태일 때만 가능)
+ const canReview = pqSubmission.status === "SUBMITTED"
+
+ return (
+ <Shell className="gap-6 max-w-5xl">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-4">
+ <Button variant="outline" size="sm" asChild>
+ <Link href="/evcp/pq_new">
+ <ArrowLeft className="w-4 h-4 mr-2" />
+ 목록으로
+ </Link>
+ </Button>
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {pqSubmission.vendorName} - {typeLabel}
+ </h2>
+ <div className="flex items-center gap-2 mt-1">
+ <Badge variant={statusVariant}>{statusLabel}</Badge>
+ {projectInfo && (
+ <span className="text-muted-foreground">
+ {projectInfo.projectName} ({projectInfo.projectCode})
+ </span>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 상태별 알림 */}
+ {pqSubmission.status === "SUBMITTED" && (
+ <Alert>
+ <AlertTitle>제출 완료</AlertTitle>
+ <AlertDescription>
+ 협력업체가 {formatDate(pqSubmission.submittedAt)}에 PQ를 제출했습니다. 검토 후 승인 또는 거부할 수 있습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {pqSubmission.status === "APPROVED" && (
+ <Alert variant="success">
+ <AlertTitle>승인됨</AlertTitle>
+ <AlertDescription>
+ {formatDate(pqSubmission.approvedAt)}에 승인되었습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {pqSubmission.status === "REJECTED" && (
+ <Alert variant="destructive">
+ <AlertTitle>거부됨</AlertTitle>
+ <AlertDescription>
+ {formatDate(pqSubmission.rejectedAt)}에 거부되었습니다.
+ {pqSubmission.rejectReason && (
+ <div className="mt-2">
+ <strong>사유:</strong> {pqSubmission.rejectReason}
+ </div>
+ )}
+ </AlertDescription>
+ </Alert>
+ )}
+
+ <Separator />
+
+ {/* PQ 검토 컴포넌트 */}
+ <Tabs defaultValue="review" className="w-full">
+ <TabsList>
+ <TabsTrigger value="review">PQ 검토</TabsTrigger>
+ <TabsTrigger value="vendor-info">협력업체 정보</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="review" className="mt-4">
+ <PQReviewWrapper
+ pqData={pqData}
+ vendorId={vendorId}
+ pqSubmission={pqSubmission}
+ canReview={canReview}
+ />
+ </TabsContent>
+
+ <TabsContent value="vendor-info" className="mt-4">
+ <div className="rounded-md border p-4">
+ <h3 className="text-lg font-medium mb-4">협력업체 정보</h3>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">업체명</p>
+ <p>{pqSubmission.vendorName}</p>
+ </div>
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">업체 코드</p>
+ <p>{pqSubmission.vendorCode}</p>
+ </div>
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">상태</p>
+ <p>{pqSubmission.vendorStatus}</p>
+ </div>
+ {/* 필요시 추가 정보 표시 */}
+ </div>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </Shell>
+ )
+ } catch (error) {
+ console.error("Error loading PQ:", error)
+ notFound()
+ }
+}
+
+// 상태 레이블 함수
+function getStatusLabel(status: string): string {
+ switch (status) {
+ case "REQUESTED":
+ return "요청됨";
+ case "IN_PROGRESS":
+ return "진행 중";
+ case "SUBMITTED":
+ return "제출됨";
+ case "APPROVED":
+ return "승인됨";
+ case "REJECTED":
+ return "거부됨";
+ default:
+ return status;
+ }
+}
+
+// 상태별 Badge 스타일
+function getStatusVariant(status: string): "default" | "outline" | "secondary" | "destructive" | "success" {
+ switch (status) {
+ case "REQUESTED":
+ return "outline";
+ case "IN_PROGRESS":
+ return "secondary";
+ case "SUBMITTED":
+ return "default";
+ case "APPROVED":
+ return "success";
+ case "REJECTED":
+ return "destructive";
+ default:
+ return "outline";
+ }
+}
+
+// 날짜 형식화 함수
+function formatDate(date: Date | null) {
+ if (!date) return "날짜 없음";
+ return new Date(date).toLocaleDateString("ko-KR", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/pq_new/page.tsx b/app/[lng]/sales/(sales)/pq_new/page.tsx
new file mode 100644
index 00000000..6598349b
--- /dev/null
+++ b/app/[lng]/sales/(sales)/pq_new/page.tsx
@@ -0,0 +1,96 @@
+import * as React from "react"
+import { Metadata } from "next"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { searchParamsPQReviewCache } from "@/lib/pq/validations"
+import { getPQSubmissions } from "@/lib/pq/service"
+import { PQSubmissionsTable } from "@/lib/pq/pq-review-table-new/vendors-table"
+
+export const metadata: Metadata = {
+ title: "PQ 검토/실사 의뢰",
+ description: "",
+}
+
+interface PQReviewPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function PQReviewPage(props: PQReviewPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsPQReviewCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // 디버깅 로그 추가
+ console.log("=== PQ Page Debug ===");
+ console.log("Raw searchParams:", searchParams);
+ console.log("Raw basicFilters param:", searchParams.basicFilters);
+ console.log("Raw pqBasicFilters param:", searchParams.pqBasicFilters);
+ console.log("Parsed search:", search);
+ console.log("search.filters:", search.filters);
+ console.log("search.basicFilters:", search.basicFilters);
+ console.log("search.pqBasicFilters:", search.pqBasicFilters);
+ console.log("validFilters:", validFilters);
+
+ // 기본 필터 처리 (통일된 이름 사용)
+ let basicFilters = []
+ if (search.basicFilters && search.basicFilters.length > 0) {
+ basicFilters = search.basicFilters
+ console.log("Using search.basicFilters:", basicFilters);
+ } else if (search.pqBasicFilters && search.pqBasicFilters.length > 0) {
+ // 하위 호환성을 위해 기존 이름도 지원
+ basicFilters = search.pqBasicFilters
+ console.log("Using search.pqBasicFilters:", basicFilters);
+ } else {
+ console.log("No basic filters found");
+ }
+
+ // 모든 필터를 합쳐서 처리
+ const allFilters = [...validFilters, ...basicFilters]
+
+ console.log("Final allFilters:", allFilters);
+
+ // 조인 연산자도 통일된 이름 사용
+ const joinOperator = search.basicJoinOperator || search.pqBasicJoinOperator || search.joinOperator || 'and';
+ console.log("Final joinOperator:", joinOperator);
+
+ // Promise.all로 감싸서 전달
+ const promises = Promise.all([
+ getPQSubmissions({
+ ...search,
+ filters: allFilters,
+ joinOperator,
+ })
+ ])
+
+ return (
+ <Shell className="gap-4">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ PQ 검토/실사 의뢰
+ </h2>
+ </div>
+ </div>
+ </div>
+
+ {/* Items처럼 직접 테이블 렌더링 */}
+ <React.Suspense
+ key={JSON.stringify(searchParams)} // URL 파라미터가 변경될 때마다 강제 리렌더링
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={2}
+ filterableColumnCount={3}
+ cellWidths={["10rem", "15rem", "12rem", "12rem", "8rem", "8rem", "10rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <PQSubmissionsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/project-gtc/page.tsx b/app/[lng]/sales/(sales)/project-gtc/page.tsx
new file mode 100644
index 00000000..8e12a489
--- /dev/null
+++ b/app/[lng]/sales/(sales)/project-gtc/page.tsx
@@ -0,0 +1,63 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getProjectGtcList } from "@/lib/project-gtc/service"
+import { projectGtcSearchParamsSchema } from "@/lib/project-gtc/validations"
+import { ProjectGtcTable } from "@/lib/project-gtc/table/project-gtc-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = projectGtcSearchParamsSchema.parse(searchParams)
+
+ const promises = Promise.all([
+ getProjectGtcList({
+ page: search.page,
+ perPage: search.perPage,
+ search: search.search,
+ sort: search.sort,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Project GTC
+ </h2>
+ <p className="text-muted-foreground">
+ 프로젝트별 GTC(General Terms and Conditions) 파일을 관리할 수 있습니다.
+ 각 프로젝트마다 하나의 GTC 파일을 업로드할 수 있으며, 파일 업로드 시 기존 파일은 자동으로 교체됩니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* 추가 기능이 필요하면 여기에 추가 */}
+ </React.Suspense>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={8}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["3rem", "3rem", "12rem", "20rem", "10rem", "20rem", "15rem", "12rem", "3rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ProjectGtcTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/project-vendors/page.tsx b/app/[lng]/sales/(sales)/project-vendors/page.tsx
new file mode 100644
index 00000000..dcc66071
--- /dev/null
+++ b/app/[lng]/sales/(sales)/project-vendors/page.tsx
@@ -0,0 +1,74 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { ProjectAVLTable } from "@/lib/project-avl/table/proejctAVL-table"
+import { getProjecTAVL } from "@/lib/project-avl/service"
+import { searchProjectAVLParamsCache } from "@/lib/project-avl/validations"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchProjectAVLParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getProjecTAVL({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 프로젝트 AVL 리스트
+ </h2>
+ <p className="text-muted-foreground">
+ 프로젝트 PQ를 통과한 벤더의 리스트를 보여줍니다.{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ProjectAVLTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/projects/page.tsx b/app/[lng]/sales/(sales)/projects/page.tsx
new file mode 100644
index 00000000..0320f259
--- /dev/null
+++ b/app/[lng]/sales/(sales)/projects/page.tsx
@@ -0,0 +1,75 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { ItemsTable } from "@/lib/items/table/items-table"
+import { getProjectLists } from "@/lib/projects/service"
+import { ProjectsTable } from "@/lib/projects/table/projects-table"
+import { searchParamsProjectsCache } from "@/lib/projects/validation"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsProjectsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getProjectLists({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Project List from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ S-EDP로부터 수신하는 프로젝트 리스트입니다. 향후 MDG로 전환됩니다.{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <ProjectsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/report/page.tsx b/app/[lng]/sales/(sales)/report/page.tsx
new file mode 100644
index 00000000..3efaa7c3
--- /dev/null
+++ b/app/[lng]/sales/(sales)/report/page.tsx
@@ -0,0 +1,47 @@
+import * as React from "react"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+
+
+export default async function IndexPage() {
+
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Dashboard
+ </h2>
+ <p className="text-muted-foreground">
+ 각종 지표 등을 대시보드로 표현하거나 리포트를 출력할 수 있습니다.
+ </p>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq-tech/[id]/cbe/page.tsx b/app/[lng]/sales/(sales)/rfq-tech/[id]/cbe/page.tsx
new file mode 100644
index 00000000..84379caf
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq-tech/[id]/cbe/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsCBECache } from "@/lib/rfqs-tech/validations"
+import { getCBE } from "@/lib/rfqs-tech/service"
+import { CbeTable } from "@/lib/rfqs-tech/cbe-table/cbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqCBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getCBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Commercial Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br />&quot;발행하기&quot; 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <CbeTable promises={promises} rfqId={idAsNumber} />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq-tech/[id]/layout.tsx b/app/[lng]/sales/(sales)/rfq-tech/[id]/layout.tsx
new file mode 100644
index 00000000..0bb62fe0
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq-tech/[id]/layout.tsx
@@ -0,0 +1,89 @@
+import { Metadata } from "next"
+import Link from "next/link"
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { RfqViewWithItems } from "@/db/schema/rfq"
+import { findRfqById } from "@/lib/rfqs-tech/service"
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+
+export const metadata: Metadata = {
+ title: "Vendor Detail",
+}
+
+export default async function RfqLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string, id: string }
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 협력업체 정보 조회
+ const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "Matched Vendors",
+ href: `/${lng}/evcp/rfq-tech/${id}`,
+ },
+ {
+ title: "TBE",
+ href: `/${lng}/evcp/rfq-tech/${id}/tbe`,
+ },
+ {
+ title: "CBE",
+ href: `/${lng}/evcp/rfq-tech/${id}/cbe`,
+ },
+
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/rfq`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>RFQ 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
+ <div className="space-y-0.5">
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {rfq
+ ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
+ : "Loading RFQ..."}
+ </h2>
+
+ <p className="text-muted-foreground">
+ {rfq
+ ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
+ : ""}
+ </p>
+ <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="lg:w-64 flex-shrink-0">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq-tech/[id]/page.tsx b/app/[lng]/sales/(sales)/rfq-tech/[id]/page.tsx
new file mode 100644
index 00000000..007270a1
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq-tech/[id]/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getMatchedVendors } from "@/lib/rfqs-tech/service"
+import { searchParamsMatchedVCache } from "@/lib/rfqs-tech/validations"
+import { MatchedVendorsTable } from "@/lib/rfqs-tech/vendor-table/vendors-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsMatchedVCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getMatchedVendors({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Vendors
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>&quot;발행하기&quot; 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq-tech/[id]/tbe/page.tsx b/app/[lng]/sales/(sales)/rfq-tech/[id]/tbe/page.tsx
new file mode 100644
index 00000000..4b226cdc
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq-tech/[id]/tbe/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getTBE } from "@/lib/rfqs-tech/service"
+import { searchParamsTBECache } from "@/lib/rfqs-tech/validations"
+import { TbeTable } from "@/lib/rfqs-tech/tbe-table/tbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Technical Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>&quot;발행하기&quot; 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <TbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq-tech/page.tsx b/app/[lng]/sales/(sales)/rfq-tech/page.tsx
new file mode 100644
index 00000000..f35b3632
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq-tech/page.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { searchParamsCache } from "@/lib/rfqs-tech/validations"
+import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs-tech/service"
+import { RfqsTable } from "@/lib/rfqs-tech/table/rfqs-table"
+import { getAllOffshoreItems } from "@/lib/items-tech/service"
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>;
+ title: string;
+ description: string;
+}
+
+export default async function RfqPage({
+ searchParams,
+ title = "기술영업 해양 RFQ",
+ description = "기술영업 해양 RFQ를 등록하고 관리할 수 있습니다."
+}: RfqPageProps) {
+ const search = searchParamsCache.parse(await searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRfqs({
+ ...search,
+ filters: validFilters,
+ }),
+ getRfqStatusCounts(),
+ getAllOffshoreItems()
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {title}
+ </h2>
+ <p className="text-muted-foreground">
+ {description}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq/[id]/cbe/page.tsx b/app/[lng]/sales/(sales)/rfq/[id]/cbe/page.tsx
new file mode 100644
index 00000000..fb288a98
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq/[id]/cbe/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsCBECache } from "@/lib/rfqs/validations"
+import { getCBE } from "@/lib/rfqs/service"
+import { CbeTable } from "@/lib/rfqs/cbe-table/cbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqCBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsCBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getCBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Commercial Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 협력업체에게 CBE를 보낼 수 있습니다. <br />"발행하기" 버튼을 통해 CBE를 전송하면 CBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <CbeTable promises={promises} rfqId={idAsNumber} />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq/[id]/layout.tsx b/app/[lng]/sales/(sales)/rfq/[id]/layout.tsx
new file mode 100644
index 00000000..9a03efa4
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq/[id]/layout.tsx
@@ -0,0 +1,89 @@
+import { Metadata } from "next"
+import Link from "next/link"
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { RfqViewWithItems } from "@/db/schema/rfq"
+import { findRfqById } from "@/lib/rfqs/service"
+import { formatDate } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+
+export const metadata: Metadata = {
+ title: "Vendor Detail",
+}
+
+export default async function RfqLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string, id: string }
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 협력업체 정보 조회
+ const rfq: RfqViewWithItems | null = await findRfqById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "Matched Vendors",
+ href: `/${lng}/evcp/rfq/${id}`,
+ },
+ {
+ title: "TBE",
+ href: `/${lng}/evcp/rfq/${id}/tbe`,
+ },
+ {
+ title: "CBE",
+ href: `/${lng}/evcp/rfq/${id}/cbe`,
+ },
+
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/rfq`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>RFQ 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
+ <div className="space-y-0.5">
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {rfq
+ ? `${rfq.projectCode ?? ""} ${rfq.rfqCode ?? ""} 관리`
+ : "Loading RFQ..."}
+ </h2>
+
+ <p className="text-muted-foreground">
+ {rfq
+ ? `${rfq.description ?? ""} ${rfq.lines.map(line => line.itemCode).join(", ")}`
+ : ""}
+ </p>
+ <h3>Due Date:{rfq && rfq?.dueDate && <strong>{formatDate(rfq?.dueDate)}</strong>}</h3>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="lg:w-64 flex-shrink-0">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="lg:w-[calc(100%-16rem)] overflow-auto">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq/[id]/page.tsx b/app/[lng]/sales/(sales)/rfq/[id]/page.tsx
new file mode 100644
index 00000000..1a9f4b18
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq/[id]/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getMatchedVendors } from "@/lib/rfqs/service"
+import { searchParamsMatchedVCache } from "@/lib/rfqs/validations"
+import { MatchedVendorsTable } from "@/lib/rfqs/vendor-table/vendors-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsMatchedVCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getMatchedVendors({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Vendors
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 등록된 협력업체 중에서 이 RFQ 아이템에 매칭되는 업체를 보여줍니다. <br/>"발행하기" 버튼을 통해 RFQ를 전송하면 첨부파일과 함께 RFQ 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <MatchedVendorsTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq/[id]/tbe/page.tsx b/app/[lng]/sales/(sales)/rfq/[id]/tbe/page.tsx
new file mode 100644
index 00000000..76eea302
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq/[id]/tbe/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getTBE } from "@/lib/rfqs/service"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { TbeTable } from "@/lib/rfqs/tbe-table/tbe-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTBE({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Technical Bid Evaluation
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>"발행하기" 버튼을 통해 TBE를 전송하면 첨부파일과 함께 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <TbeTable promises={promises} rfqId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/rfq/page.tsx b/app/[lng]/sales/(sales)/rfq/page.tsx
new file mode 100644
index 00000000..3417b0bf
--- /dev/null
+++ b/app/[lng]/sales/(sales)/rfq/page.tsx
@@ -0,0 +1,80 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { searchParamsCache } from "@/lib/rfqs/validations"
+import { getRfqs, getRfqStatusCounts } from "@/lib/rfqs/service"
+import { RfqsTable } from "@/lib/rfqs/table/rfqs-table"
+import { getAllItems } from "@/lib/items/service"
+import { RfqType } from "@/lib/rfqs/validations"
+
+interface RfqPageProps {
+ searchParams: Promise<SearchParams>;
+ rfqType: RfqType;
+ title: string;
+ description: string;
+}
+
+export default async function RfqPage({
+ searchParams,
+ rfqType = RfqType.PURCHASE,
+ title = "RFQ",
+ description = "RFQ를 등록하고 관리할 수 있습니다."
+}: RfqPageProps) {
+ const search = searchParamsCache.parse(await searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRfqs({
+ ...search,
+ filters: validFilters,
+ rfqType // 전달받은 rfqType 사용
+ }),
+ getRfqStatusCounts(rfqType), // rfqType 전달
+ getAllItems()
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ {title}
+ </h2>
+ <p className="text-muted-foreground">
+ {description}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <RfqsTable promises={promises} rfqType={rfqType} />
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/settings/layout.tsx b/app/[lng]/sales/(sales)/settings/layout.tsx
new file mode 100644
index 00000000..6f373567
--- /dev/null
+++ b/app/[lng]/sales/(sales)/settings/layout.tsx
@@ -0,0 +1,68 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "Settings",
+ // description: "Advanced form example using react-hook-form and Zod.",
+}
+
+
+interface SettingsLayoutProps {
+ children: React.ReactNode
+ params: { lng: string }
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+
+ const sidebarNavItems = [
+
+ {
+ title: "Account",
+ href: `/${lng}/evcp/settings`,
+ },
+ {
+ title: "Preferences",
+ href: `/${lng}/evcp/settings/preferences`,
+ }
+
+
+ ]
+
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">Settings</h2>
+ <p className="text-muted-foreground">
+ Manage your account settings and preferences.
+ </p>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+
+
+ </>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/settings/page.tsx b/app/[lng]/sales/(sales)/settings/page.tsx
new file mode 100644
index 00000000..a6eaac90
--- /dev/null
+++ b/app/[lng]/sales/(sales)/settings/page.tsx
@@ -0,0 +1,18 @@
+import { Separator } from "@/components/ui/separator"
+import { AccountForm } from "@/components/settings/account-form"
+
+export default function SettingsAccountPage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Account</h3>
+ <p className="text-sm text-muted-foreground">
+ Update your account settings. Set your preferred language and
+ timezone.
+ </p>
+ </div>
+ <Separator />
+ <AccountForm />
+ </div>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/settings/preferences/page.tsx b/app/[lng]/sales/(sales)/settings/preferences/page.tsx
new file mode 100644
index 00000000..e2a88021
--- /dev/null
+++ b/app/[lng]/sales/(sales)/settings/preferences/page.tsx
@@ -0,0 +1,17 @@
+import { Separator } from "@/components/ui/separator"
+import { AppearanceForm } from "@/components/settings/appearance-form"
+
+export default function SettingsAppearancePage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Preference</h3>
+ <p className="text-sm text-muted-foreground">
+ Customize the preference of the app.
+ </p>
+ </div>
+ <Separator />
+ <AppearanceForm />
+ </div>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/system/admin-users/page.tsx b/app/[lng]/sales/(sales)/system/admin-users/page.tsx
new file mode 100644
index 00000000..11a9e9fb
--- /dev/null
+++ b/app/[lng]/sales/(sales)/system/admin-users/page.tsx
@@ -0,0 +1,60 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { DateRangePicker } from "@/components/date-range-picker"
+import { Separator } from "@/components/ui/separator"
+
+import { searchParamsCache } from "@/lib/admin-users/validations"
+import { getAllCompanies, getAllRoles, getUserCountGroupByCompany, getUserCountGroupByRole, getUsers } from "@/lib/admin-users/service"
+import { AdmUserTable } from "@/lib/admin-users/table/ausers-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function UserTable(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getUsers({
+ ...search,
+ filters: validFilters,
+ }),
+ getUserCountGroupByCompany(),
+ getUserCountGroupByRole(),
+ getAllCompanies(),
+ getAllRoles()
+ ])
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Vendor Admin User Management</h3>
+ <p className="text-sm text-muted-foreground">
+ 협력업체의 유저 전체를 조회하고 어드민 유저를 생성할 수 있는 페이지입니다. 이곳에서 초기 유저를 생성시킬 수 있습니다. <br />생성 후에는 생성된 사용자의 이메일로 생성 통보 이메일이 발송되며 사용자는 이메일과 OTP로 로그인이 가능합니다.
+ </p>
+ </div>
+ <Separator />
+ <AdmUserTable promises={promises} />
+ </div>
+ </React.Suspense>
+
+ )
+}
diff --git a/app/[lng]/sales/(sales)/system/layout.tsx b/app/[lng]/sales/(sales)/system/layout.tsx
new file mode 100644
index 00000000..7e8f69d0
--- /dev/null
+++ b/app/[lng]/sales/(sales)/system/layout.tsx
@@ -0,0 +1,80 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+
+export const metadata: Metadata = {
+ title: "System Setting",
+ // description: "Advanced form example using react-hook-form and Zod.",
+}
+
+
+interface SettingsLayoutProps {
+ children: React.ReactNode
+ params: { lng: string }
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string }
+}) {
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+
+
+ const sidebarNavItems = [
+
+ {
+ title: "삼성중공업 사용자",
+ href: `/${lng}/evcp/system`,
+ },
+ {
+ title: "Roles",
+ href: `/${lng}/evcp/system/roles`,
+ },
+ {
+ title: "권한 통제",
+ href: `/${lng}/evcp/system/permissions`,
+ },
+ {
+ title: "협력업체 사용자",
+ href: `/${lng}/evcp/system/admin-users`,
+ },
+
+ {
+ title: "비밀번호 정책",
+ href: `/${lng}/evcp/system/password-policy`,
+ },
+
+ ]
+
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ <div className="space-y-0.5">
+ <h2 className="text-2xl font-bold tracking-tight">시스템 설정</h2>
+ <p className="text-muted-foreground">
+ 사용자, 롤, 접근 권한을 관리하세요.
+ </p>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1 ">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+
+
+ </>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/system/page.tsx b/app/[lng]/sales/(sales)/system/page.tsx
new file mode 100644
index 00000000..fe0a262c
--- /dev/null
+++ b/app/[lng]/sales/(sales)/system/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import * as React from "react"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsCache } from "@/lib/admin-users/validations"
+import { getAllRoles, getUsersEVCP } from "@/lib/users/service"
+import { getUserCountGroupByRole } from "@/lib/admin-users/service"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { UserTable } from "@/lib/users/table/users-table"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SystemUserPage(props: IndexPageProps) {
+
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getUsersEVCP({
+ ...search,
+ filters: validFilters,
+ }),
+ getUserCountGroupByRole(),
+ getAllRoles()
+ ])
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "12rem", "12rem", "12rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">SHI Users</h3>
+ <p className="text-sm text-muted-foreground">
+ 시스템 전체 사용자들을 조회하고 관리할 수 있는 페이지입니다. 사용자에게 롤을 할당하는 것으로 메뉴별 권한을 관리할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <UserTable promises={promises} />
+ </div>
+ </React.Suspense>
+
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/system/password-policy/page.tsx b/app/[lng]/sales/(sales)/system/password-policy/page.tsx
new file mode 100644
index 00000000..0f14fefe
--- /dev/null
+++ b/app/[lng]/sales/(sales)/system/password-policy/page.tsx
@@ -0,0 +1,63 @@
+// app/admin/password-policy/page.tsx
+
+import * as React from "react"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Separator } from "@/components/ui/separator"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { AlertTriangle } from "lucide-react"
+import SecuritySettingsTable from "@/components/system/passwordPolicy"
+import { getSecuritySettings } from "@/lib/password-policy/service"
+
+
+export default async function PasswordPolicyPage() {
+ try {
+ // 보안 설정 데이터 로드
+ const securitySettings = await getSecuritySettings()
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={4}
+ searchableColumnCount={0}
+ filterableColumnCount={0}
+ cellWidths={["20rem", "30rem", "15rem", "10rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3>
+ <p className="text-sm text-muted-foreground">
+ 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <SecuritySettingsTable initialSettings={securitySettings} />
+ </div>
+ </React.Suspense>
+ )
+ } catch (error) {
+ console.error('Failed to load security settings:', error)
+
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">협력업체 사용자 비밀번호 정책 설정</h3>
+ <p className="text-sm text-muted-foreground">
+ 협력업체 사용자들을 위한 비밀번호 정책과 보안 설정을 관리할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ 보안 설정을 불러오는 중 오류가 발생했습니다. 페이지를 새로고침하거나 관리자에게 문의하세요.
+ </AlertDescription>
+ </Alert>
+ </div>
+ )
+ }
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/system/permissions/page.tsx b/app/[lng]/sales/(sales)/system/permissions/page.tsx
new file mode 100644
index 00000000..6aa2b693
--- /dev/null
+++ b/app/[lng]/sales/(sales)/system/permissions/page.tsx
@@ -0,0 +1,17 @@
+import PermissionsTree from "@/components/system/permissionsTree"
+import { Separator } from "@/components/ui/separator"
+
+export default function PermissionsPage() {
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Permissions</h3>
+ <p className="text-sm text-muted-foreground">
+ Set permissions to the menu by Role
+ </p>
+ </div>
+ <Separator />
+ <PermissionsTree/>
+ </div>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/system/roles/page.tsx b/app/[lng]/sales/(sales)/system/roles/page.tsx
new file mode 100644
index 00000000..fe074600
--- /dev/null
+++ b/app/[lng]/sales/(sales)/system/roles/page.tsx
@@ -0,0 +1,68 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Separator } from "@/components/ui/separator"
+
+import { searchParamsCache } from "@/lib/roles/validations"
+import { searchParamsCache as searchParamsCache2 } from "@/lib/admin-users/validations"
+import { RolesTable } from "@/lib/roles/table/roles-table"
+import { getRolesWithCount } from "@/lib/roles/services"
+import { getUsersAll } from "@/lib/users/service"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function UserTable(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+ const search2 = searchParamsCache2.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRolesWithCount({
+ ...search,
+ filters: validFilters,
+ }),
+
+
+ ])
+
+
+ const promises2 = Promise.all([
+ getUsersAll({
+ ...search2,
+ filters: validFilters,
+ }, "evcp"),
+ ])
+
+
+ return (
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">Role Management</h3>
+ <p className="text-sm text-muted-foreground">
+ 역할을 생성하고 역할에 유저를 할당할 수 있는 페이지입니다. 역할에 메뉴의 접근 권한 역시 할당할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <RolesTable promises={promises} promises2={promises2} />
+ </div>
+ </React.Suspense>
+
+ )
+}
diff --git a/app/[lng]/sales/(sales)/tag-numbering/page.tsx b/app/[lng]/sales/(sales)/tag-numbering/page.tsx
new file mode 100644
index 00000000..44695259
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tag-numbering/page.tsx
@@ -0,0 +1,74 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/tag-numbering/validation"
+import { getTagNumbering } from "@/lib/tag-numbering/service"
+import { TagNumberingTable } from "@/lib/tag-numbering/table/tagNumbering-table"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTagNumbering({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 태그 타입 목록 from S-EDP
+ </h2>
+ <p className="text-muted-foreground">
+ 태그 넘버링을 위한 룰셋을 S-EDP로부터 가져오고 확인할 수 있습니다{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <TagNumberingTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/tasks/page.tsx b/app/[lng]/sales/(sales)/tasks/page.tsx
new file mode 100644
index 00000000..91b946fb
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tasks/page.tsx
@@ -0,0 +1,63 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { DateRangePicker } from "@/components/date-range-picker"
+import { Shell } from "@/components/shell"
+
+import { FeatureFlagsProvider } from "@/lib/tasks/table/feature-flags-provider"
+import { TasksTable } from "@/lib/tasks/table/tasks-table"
+import {
+ getTaskPriorityCounts,
+ getTasks,
+ getTaskStatusCounts,
+} from "@/lib/tasks/service"
+import { searchParamsCache } from "@/lib/tasks/validations"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getTasks({
+ ...search,
+ filters: validFilters,
+ }),
+ getTaskStatusCounts(),
+ getTaskPriorityCounts(),
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ />
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <TasksTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/tbe-tech/page.tsx b/app/[lng]/sales/(sales)/tbe-tech/page.tsx
new file mode 100644
index 00000000..17b01ce2
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tbe-tech/page.tsx
@@ -0,0 +1,67 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getAllTBE } from "@/lib/rfqs-tech/service"
+import { searchParamsTBECache } from "@/lib/rfqs-tech/validations"
+import { AllTbeTable } from "@/lib/tbe-tech/table/tbe-table"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+
+interface IndexPageProps {
+ params: {
+ lng: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+
+ // URL 쿼리 파라미터에서 타입 추출
+ const searchParams = await props.searchParams
+
+ // SearchParams 파싱 (Zod)
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // 현재 선택된 타입의 데이터 로드
+ const promises = Promise.all([
+ getAllTBE({
+ ...search,
+ filters: validFilters,
+ })
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Technical Bid Evaluation
+ </h2>
+ <p className="text-muted-foreground">
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>
+ 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <AllTbeTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tbe/page.tsx b/app/[lng]/sales/(sales)/tbe/page.tsx
new file mode 100644
index 00000000..1a7fdf86
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tbe/page.tsx
@@ -0,0 +1,113 @@
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { getAllTBE } from "@/lib/rfqs/service"
+import { searchParamsTBECache } from "@/lib/rfqs/validations"
+import { AllTbeTable } from "@/lib/tbe/table/tbe-table"
+import { RfqType } from "@/lib/rfqs/validations"
+import * as React from "react"
+import { Shell } from "@/components/shell"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
+
+interface IndexPageProps {
+ params: {
+ lng: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+// 타입별 페이지 설명 구성 (Budgetary 제외)
+const typeConfig: Record<string, { title: string; description: string; rfqType: RfqType }> = {
+ "purchase": {
+ title: "Purchase RFQ Technical Bid Evaluation",
+ description: "실제 구매 발주 전 가격 요청을 위한 TBE입니다.",
+ rfqType: RfqType.PURCHASE
+ },
+ "purchase-budgetary": {
+ title: "Purchase Budgetary RFQ Technical Bid Evaluation",
+ description: "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 TBE입니다.",
+ rfqType: RfqType.PURCHASE_BUDGETARY
+ }
+}
+
+export default async function RfqTBEPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+
+ // URL 쿼리 파라미터에서 타입 추출
+ const searchParams = await props.searchParams
+ // 기본값으로 'purchase' 사용
+ const typeParam = searchParams?.type as string || 'purchase'
+
+ // 유효한 타입인지 확인하고 기본값 설정
+ const validType = Object.keys(typeConfig).includes(typeParam) ? typeParam : 'purchase'
+ const rfqType = typeConfig[validType].rfqType
+
+ // SearchParams 파싱 (Zod)
+ const search = searchParamsTBECache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ // 현재 선택된 타입의 데이터 로드
+ const promises = Promise.all([
+ getAllTBE({
+ ...search,
+ filters: validFilters,
+ rfqType
+ })
+ ])
+
+ // 페이지 경로 생성 함수 - 단순화
+ const getTabUrl = (type: string) => {
+ return `/${lng}/evcp/tbe?type=${type}`;
+ }
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Technical Bid Evaluation
+ </h2>
+ <p className="text-muted-foreground">
+ 초대된 협력업체에게 TBE를 보낼 수 있습니다. <br/>
+ 체크박스 선택을 하면 초대 버튼이 활성화됩니다. 버튼 클릭 후 첨부파일을 함께 전송하면 TBE 내용이 메일로 전달되고 eVCP에도 협력업체가 입력할 수 있게 자동 생성됩니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {/* 타입 선택 탭 (Budgetary 제외) */}
+ <Tabs defaultValue={validType} value={validType} className="w-full">
+ <TabsList className="grid grid-cols-2 w-full max-w-md">
+ <TabsTrigger value="purchase" asChild>
+ <a href={getTabUrl('purchase')}>Purchase</a>
+ </TabsTrigger>
+ <TabsTrigger value="purchase-budgetary" asChild>
+ <a href={getTabUrl('purchase-budgetary')}>Purchase Budgetary</a>
+ </TabsTrigger>
+ </TabsList>
+
+ <div className="mt-2">
+ <p className="text-sm text-muted-foreground">
+ {typeConfig[validType].description}
+ </p>
+ </div>
+ </Tabs>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <AllTbeTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-project-avl/page.tsx b/app/[lng]/sales/(sales)/tech-project-avl/page.tsx
new file mode 100644
index 00000000..d942c5c5
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tech-project-avl/page.tsx
@@ -0,0 +1,85 @@
+import * as React from "react"
+import { redirect } from "next/navigation"
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+import { SearchParams } from "@/types/table"
+import { searchParamsCache } from "@/lib/tech-project-avl/validations"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Shell } from "@/components/shell"
+import { AcceptedQuotationsTable } from "@/lib/tech-project-avl/table/accepted-quotations-table"
+import { getAcceptedTechSalesVendorQuotations } from "@/lib/techsales-rfq/service"
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Ellipsis } from "lucide-react"
+
+export interface PageProps {
+ params: Promise<{ lng: string }>
+ searchParams: Promise<SearchParams>
+}
+
+export default async function AcceptedQuotationsPage({
+ params,
+ searchParams,
+}: PageProps) {
+ const { lng } = await params
+
+ const session = await getServerSession(authOptions)
+ if (!session) {
+ redirect(`/${lng}/auth/signin`)
+ }
+
+ const search = await searchParams
+ const { page, perPage, sort, filters, search: searchText } = searchParamsCache.parse(search)
+ const validFilters = getValidFilters(filters ?? [])
+
+ const { data, pageCount } = await getAcceptedTechSalesVendorQuotations({
+ page,
+ perPage: perPage ?? 10,
+ sort,
+ search: searchText,
+ filters: validFilters,
+ })
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 승인된 견적서(해양TOP,HULL)
+ </h2>
+ <p className="text-muted-foreground">
+ 기술영업 승인 견적서에 대한 요약 정보를 확인하고{" "}
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 RFQ 코드, 설명, 업체명, 업체 코드 등의 상세 정보를 확인할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* Date range picker can be added here if needed */}
+ </React.Suspense>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={12}
+ searchableColumnCount={2}
+ filterableColumnCount={4}
+ cellWidths={["10rem", "15rem", "12rem", "10rem", "10rem", "12rem", "8rem", "12rem", "10rem", "8rem", "10rem", "10rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <AcceptedQuotationsTable
+ data={data}
+ pageCount={pageCount}
+ />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/tech-vendor-candidates/page.tsx b/app/[lng]/sales/(sales)/tech-vendor-candidates/page.tsx
new file mode 100644
index 00000000..3923863a
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tech-vendor-candidates/page.tsx
@@ -0,0 +1,78 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { getVendorCandidateCounts, getVendorCandidates } from "@/lib/tech-vendor-candidates/service"
+import { searchParamsTechCandidateCache } from "@/lib/tech-vendor-candidates/validations"
+import { VendorCandidateTable as TechVendorCandidateTable } from "@/lib/tech-vendor-candidates/table/candidates-table"
+import { DateRangePicker } from "@/components/date-range-picker"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsTechCandidateCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendorCandidates({
+ ...search,
+ filters: validFilters,
+ }),
+ getVendorCandidateCounts()
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Vendor Candidates Management
+ </h2>
+ <p className="text-muted-foreground">
+ 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {/* 수집일 라벨과 DateRangePicker를 함께 배치 */}
+ <div className="flex items-center justify-start gap-2">
+ {/* <span className="text-sm font-medium">수집일 기간 설정: </span> */}
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ showClearButton={true}
+ placeholder="수집일 날짜 범위를 고르세요"
+ />
+ </React.Suspense>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <TechVendorCandidateTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/items/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/items/page.tsx
new file mode 100644
index 00000000..69c36576
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/items/page.tsx
@@ -0,0 +1,48 @@
+// import { Separator } from "@/components/ui/separator"
+// import { getTechVendorById, getVendorItemsByType } from "@/lib/tech-vendors/service"
+// import { type SearchParams } from "@/types/table"
+// import { TechVendorItemsTable } from "@/lib/tech-vendors/items-table/item-table"
+
+// interface IndexPageProps {
+// // Next.js 13 App Router에서 기본으로 주어지는 객체들
+// params: {
+// lng: string
+// id: string
+// }
+// searchParams: Promise<SearchParams>
+// }
+
+// export default async function TechVendorItemsPage(props: IndexPageProps) {
+// const resolvedParams = await props.params
+// const id = resolvedParams.id
+
+// const idAsNumber = Number(id)
+
+// // 벤더 정보 가져오기 (벤더 타입 필요)
+// const vendorInfo = await getTechVendorById(idAsNumber)
+// const vendorType = vendorInfo.data?.techVendorType || "조선"
+
+// const promises = getVendorItemsByType(idAsNumber, vendorType)
+
+// // 4) 렌더링
+// return (
+// <div className="space-y-6">
+// <div>
+// <h3 className="text-lg font-medium">
+// 공급품목
+// </h3>
+// <p className="text-sm text-muted-foreground">
+// 기술영업 벤더의 공급 가능한 품목을 확인하세요.
+// </p>
+// </div>
+// <Separator />
+// <div>
+// <TechVendorItemsTable
+// promises={promises}
+// vendorId={idAsNumber}
+// vendorType={vendorType}
+// />
+// </div>
+// </div>
+// )
+// } \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx
new file mode 100644
index 00000000..7c389720
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/layout.tsx
@@ -0,0 +1,82 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { findTechVendorById } from "@/lib/tech-vendors/service"
+import { TechVendor } from "@/db/schema/techVendors"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+import Link from "next/link"
+export const metadata: Metadata = {
+ title: "Tech Vendor Detail",
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string , id: string}
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 협력업체 정보 조회
+ const vendor: TechVendor | null = await findTechVendorById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "연락처",
+ href: `/${lng}/evcp/tech-vendors/${id}/info`,
+ },
+ // {
+ // title: "자재 리스트",
+ // href: `/${lng}/evcp/tech-vendors/${id}/info/items`,
+ // },
+ // {
+ // title: "견적 히스토리",
+ // href: `/${lng}/evcp/tech-vendors/${id}/info/rfq-history`,
+ // },
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ {/* RFQ 목록으로 돌아가는 링크 추가 */}
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/tech-vendors`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>기술영업 벤더 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
+ <div className="space-y-0.5">
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {vendor
+ ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보`
+ : "Loading Vendor..."}
+ </h2>
+ <p className="text-muted-foreground">기술영업 벤더 관련 상세사항을 확인하세요.</p>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx
new file mode 100644
index 00000000..a57d6df7
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { getTechVendorContacts } from "@/lib/tech-vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsContactCache } from "@/lib/tech-vendors/validations"
+import { TechVendorContactsTable } from "@/lib/tech-vendors/contacts-table/contact-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SettingsAccountPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsContactCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+
+
+ const promises = Promise.all([
+ getTechVendorContacts({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Contacts
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 업무별 담당자 정보를 확인하세요.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <TechVendorContactsTable promises={promises} vendorId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx
new file mode 100644
index 00000000..4ed2b39f
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tech-vendors/[id]/info/rfq-history/page.tsx
@@ -0,0 +1,55 @@
+// import { Separator } from "@/components/ui/separator"
+// import { getRfqHistory } from "@/lib/vendors/service"
+// import { type SearchParams } from "@/types/table"
+// import { getValidFilters } from "@/lib/data-table"
+// import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
+// import { TechVendorRfqHistoryTable } from "@/lib/tech-vendors/rfq-history-table/rfq-history-table"
+
+// interface IndexPageProps {
+// // Next.js 13 App Router에서 기본으로 주어지는 객체들
+// params: {
+// lng: string
+// id: string
+// }
+// searchParams: Promise<SearchParams>
+// }
+
+// export default async function RfqHistoryPage(props: IndexPageProps) {
+// const resolvedParams = await props.params
+// const lng = resolvedParams.lng
+// const id = resolvedParams.id
+
+// const idAsNumber = Number(id)
+
+// // 2) SearchParams 파싱 (Zod)
+// // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+// const searchParams = await props.searchParams
+// const search = searchParamsRfqHistoryCache.parse(searchParams)
+// const validFilters = getValidFilters(search.filters)
+
+// const promises = Promise.all([
+// getRfqHistory({
+// ...search,
+// filters: validFilters,
+// },
+// idAsNumber)
+// ])
+
+// // 4) 렌더링
+// return (
+// <div className="space-y-6">
+// <div>
+// <h3 className="text-lg font-medium">
+// RFQ History
+// </h3>
+// <p className="text-sm text-muted-foreground">
+// 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
+// </p>
+// </div>
+// <Separator />
+// <div>
+// <TechVendorRfqHistoryTable promises={promises} vendorId={idAsNumber} />
+// </div>
+// </div>
+// )
+// } \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/tech-vendors/page.tsx b/app/[lng]/sales/(sales)/tech-vendors/page.tsx
new file mode 100644
index 00000000..8f542f59
--- /dev/null
+++ b/app/[lng]/sales/(sales)/tech-vendors/page.tsx
@@ -0,0 +1,58 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { searchParamsCache } from "@/lib/tech-vendors/validations"
+import { getTechVendors, getTechVendorStatusCounts } from "@/lib/tech-vendors/service"
+import { TechVendorsTable } from "@/lib/tech-vendors/table/tech-vendors-table"
+import { TechVendorContainer } from "@/components/tech-vendors/tech-vendor-container"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ // 벤더 타입 정의
+ const vendorTypes = [
+ { id: "all", name: "전체", value: "" },
+ { id: "ship", name: "조선", value: "조선" },
+ { id: "top", name: "해양TOP", value: "해양TOP" },
+ { id: "hull", name: "해양HULL", value: "해양HULL" },
+ ]
+
+ const promises = Promise.all([
+ getTechVendors({
+ ...search,
+ filters: validFilters,
+ }),
+ getTechVendorStatusCounts(),
+ ])
+
+ return (
+ <Shell className="gap-4">
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <TechVendorContainer vendorTypes={vendorTypes}>
+ <TechVendorsTable promises={promises} />
+ </TechVendorContainer>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/vendor-candidates/page.tsx b/app/[lng]/sales/(sales)/vendor-candidates/page.tsx
new file mode 100644
index 00000000..a6e00b1b
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendor-candidates/page.tsx
@@ -0,0 +1,78 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { getVendorCandidateCounts, getVendorCandidates } from "@/lib/vendor-candidates/service"
+import { searchParamsCandidateCache } from "@/lib/vendor-candidates/validations"
+import { VendorCandidateTable } from "@/lib/vendor-candidates/table/candidates-table"
+import { DateRangePicker } from "@/components/date-range-picker"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCandidateCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendorCandidates({
+ ...search,
+ filters: validFilters,
+ }),
+ getVendorCandidateCounts()
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Vendor Candidates Management
+ </h2>
+ <p className="text-muted-foreground">
+ 수집한 협력업체 후보를 등록하고 초대 메일을 송부할 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ {/* 수집일 라벨과 DateRangePicker를 함께 배치 */}
+ <div className="flex items-center justify-start gap-2">
+ {/* <span className="text-sm font-medium">수집일 기간 설정: </span> */}
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ showClearButton={true}
+ placeholder="수집일 날짜 범위를 고르세요"
+ />
+ </React.Suspense>
+ </div>
+
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <VendorCandidateTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/vendor-check-list/page.tsx b/app/[lng]/sales/(sales)/vendor-check-list/page.tsx
new file mode 100644
index 00000000..3fd7e425
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendor-check-list/page.tsx
@@ -0,0 +1,74 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { getGenralEvaluationsSchema } from "@/lib/general-check-list/validation"
+import { GeneralEvaluationsTable } from "@/lib/general-check-list/table/general-check-list-table"
+import { getGeneralEvaluations } from "@/lib/general-check-list/service"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = getGenralEvaluationsSchema.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getGeneralEvaluations({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 협력업체 정기평가 체크리스트
+ </h2>
+ <p className="text-muted-foreground">
+ 협력업체 평가에 사용되는 정기평가 체크리스트를 관리{" "}
+ {/* <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. */}
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <GeneralEvaluationsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/vendor-investigation/page.tsx b/app/[lng]/sales/(sales)/vendor-investigation/page.tsx
new file mode 100644
index 00000000..c59de869
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendor-investigation/page.tsx
@@ -0,0 +1,65 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+import { VendorsInvestigationTable } from "@/lib/vendor-investigation/table/investigation-table"
+import { getVendorsInvestigation } from "@/lib/vendor-investigation/service"
+import { searchParamsInvestigationCache } from "@/lib/vendor-investigation/validations"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsInvestigationCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendorsInvestigation({
+ ...search,
+ filters: validFilters,
+ }),
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ Vendor Investigation Management
+ </h2>
+ <p className="text-muted-foreground">
+ 요청된 Vendor 실사에 대한 스케줄 정보를 관리하고 결과를 입력할 수 있습니다.
+
+ </p>
+ </div>
+ </div>
+ </div>
+
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <VendorsInvestigationTable promises={promises}/>
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/vendor-type/page.tsx b/app/[lng]/sales/(sales)/vendor-type/page.tsx
new file mode 100644
index 00000000..997c0f82
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendor-type/page.tsx
@@ -0,0 +1,70 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+import { searchParamsCache } from "@/lib/vendor-type/validations"
+import { VendorTypesTable } from "@/lib/vendor-type/table/vendorTypes-table"
+import { getVendorTypes } from "@/lib/vendor-type/service"
+
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendorTypes({
+ ...search,
+ filters: validFilters,
+ }),
+
+ ])
+
+ return (
+ <Shell className="gap-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 업체 유형
+ </h2>
+ <p className="text-muted-foreground">
+ 업체 유형을 등록하고 관리할 수 있습니다.{" "}
+
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <VendorTypesTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}
diff --git a/app/[lng]/sales/(sales)/vendors/[id]/info/items/page.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/items/page.tsx
new file mode 100644
index 00000000..5d5838c6
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendors/[id]/info/items/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { getVendorItems } from "@/lib/vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsItemCache } from "@/lib/vendors/validations"
+import { VendorItemsTable } from "@/lib/vendors/items-table/item-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SettingsAccountPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsItemCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+
+
+ const promises = Promise.all([
+ getVendorItems({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ 공급품목(패키지)
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ {/* 딜리버리가 가능한 아이템 리스트를 확인할 수 있습니다. */}
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorItemsTable promises={promises} vendorId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/vendors/[id]/info/layout.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/layout.tsx
new file mode 100644
index 00000000..7e2cd4f6
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendors/[id]/info/layout.tsx
@@ -0,0 +1,94 @@
+import { Metadata } from "next"
+
+import { Separator } from "@/components/ui/separator"
+import { SidebarNav } from "@/components/layout/sidebar-nav"
+import { findVendorById } from "@/lib/vendors/service" // 가정: 여기에 findVendorById가 있다고 가정
+import { Vendor } from "@/db/schema/vendors"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft } from "lucide-react"
+import Link from "next/link"
+export const metadata: Metadata = {
+ title: "Vendor Detail",
+}
+
+export default async function SettingsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: { lng: string , id: string}
+}) {
+
+ // 1) URL 파라미터에서 id 추출, Number로 변환
+ const resolvedParams = await params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+ // 2) DB에서 해당 협력업체 정보 조회
+ const vendor: Vendor | null = await findVendorById(idAsNumber)
+
+ // 3) 사이드바 메뉴
+ const sidebarNavItems = [
+ {
+ title: "연락처",
+ href: `/${lng}/evcp/vendors/${id}/info`,
+ },
+ {
+ title: "공급품목(패키지)",
+ href: `/${lng}/evcp/vendors/${id}/info/items`,
+ },
+ {
+ title: "공급품목(자재그룹)",
+ href: `/${lng}/evcp/vendors/${id}/info/materials`,
+ },
+ {
+ title: "견적 히스토리",
+ href: `/${lng}/evcp/vendors/${id}/info/rfq-history`,
+ },
+ {
+ title: "입찰 히스토리",
+ href: `/${lng}/evcp/vendors/${id}/info/bid-history`,
+ },
+ {
+ title: "계약 히스토리",
+ href: `/${lng}/evcp/vendors/${id}/info/contract-history`,
+ },
+ ]
+
+ return (
+ <>
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-[0.5rem] border bg-background shadow">
+ <div className="hidden space-y-6 p-10 pb-16 md:block">
+ {/* RFQ 목록으로 돌아가는 링크 추가 */}
+ <div className="flex items-center justify-end mb-4">
+ <Link href={`/${lng}/evcp/vendors`} passHref>
+ <Button variant="ghost" className="flex items-center text-primary hover:text-primary/80 transition-colors p-0 h-auto">
+ <ArrowLeft className="mr-1 h-4 w-4" />
+ <span>협력업체 목록으로 돌아가기</span>
+ </Button>
+ </Link>
+ </div>
+ <div className="space-y-0.5">
+ {/* 4) 협력업체 정보가 있으면 코드 + 이름 + "상세 정보" 표기 */}
+ <h2 className="text-2xl font-bold tracking-tight">
+ {vendor
+ ? `${vendor.vendorCode ?? ""} - ${vendor.vendorName} 상세 정보`
+ : "Loading Vendor..."}
+ </h2>
+ <p className="text-muted-foreground">협력업체 관련 상세사항을 확인하세요.</p>
+ </div>
+ <Separator className="my-6" />
+ <div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
+ <aside className="-mx-4 lg:w-1/5">
+ <SidebarNav items={sidebarNavItems} />
+ </aside>
+ <div className="flex-1">{children}</div>
+ </div>
+ </div>
+ </section>
+ </div>
+ </>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/vendors/[id]/info/materials/page.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/materials/page.tsx
new file mode 100644
index 00000000..0ebb66ba
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendors/[id]/info/materials/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsMaterialCache } from "@/lib/vendors/validations"
+import { getVendorMaterials } from "@/lib/vendors/service"
+import { VendorMaterialsTable } from "@/lib/vendors/materials-table/item-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SettingsAccountPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsMaterialCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+
+
+ const promises = Promise.all([
+ getVendorMaterials({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ 공급품목(자재 그룹)
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ {/* 딜리버리가 가능한 공급품목(자재 그룹)을 확인할 수 있습니다. */}
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorMaterialsTable promises={promises} vendorId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/vendors/[id]/info/page.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/page.tsx
new file mode 100644
index 00000000..6279e924
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendors/[id]/info/page.tsx
@@ -0,0 +1,56 @@
+import { Separator } from "@/components/ui/separator"
+import { getVendorContacts } from "@/lib/vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsContactCache } from "@/lib/vendors/validations"
+import { VendorContactsTable } from "@/lib/vendors/contacts-table/contact-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function SettingsAccountPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsContactCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+
+
+ const promises = Promise.all([
+ getVendorContacts({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ Contacts
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 업무별 담당자 정보를 확인하세요.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorContactsTable promises={promises} vendorId={idAsNumber}/>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/vendors/[id]/info/rfq-history/page.tsx b/app/[lng]/sales/(sales)/vendors/[id]/info/rfq-history/page.tsx
new file mode 100644
index 00000000..c7f8f8b6
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendors/[id]/info/rfq-history/page.tsx
@@ -0,0 +1,55 @@
+import { Separator } from "@/components/ui/separator"
+import { getRfqHistory } from "@/lib/vendors/service"
+import { type SearchParams } from "@/types/table"
+import { getValidFilters } from "@/lib/data-table"
+import { searchParamsRfqHistoryCache } from "@/lib/vendors/validations"
+import { VendorRfqHistoryTable } from "@/lib/vendors/rfq-history-table/rfq-history-table"
+
+interface IndexPageProps {
+ // Next.js 13 App Router에서 기본으로 주어지는 객체들
+ params: {
+ lng: string
+ id: string
+ }
+ searchParams: Promise<SearchParams>
+}
+
+export default async function RfqHistoryPage(props: IndexPageProps) {
+ const resolvedParams = await props.params
+ const lng = resolvedParams.lng
+ const id = resolvedParams.id
+
+ const idAsNumber = Number(id)
+
+ // 2) SearchParams 파싱 (Zod)
+ // - "filters", "page", "perPage", "sort" 등 contact 전용 컬럼
+ const searchParams = await props.searchParams
+ const search = searchParamsRfqHistoryCache.parse(searchParams)
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getRfqHistory({
+ ...search,
+ filters: validFilters,
+ },
+ idAsNumber)
+ ])
+
+ // 4) 렌더링
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-lg font-medium">
+ RFQ History
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ 협력업체의 RFQ 참여 이력을 확인할 수 있습니다.
+ </p>
+ </div>
+ <Separator />
+ <div>
+ <VendorRfqHistoryTable promises={promises} />
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/app/[lng]/sales/(sales)/vendors/page.tsx b/app/[lng]/sales/(sales)/vendors/page.tsx
new file mode 100644
index 00000000..52af0709
--- /dev/null
+++ b/app/[lng]/sales/(sales)/vendors/page.tsx
@@ -0,0 +1,78 @@
+import * as React from "react"
+import { type SearchParams } from "@/types/table"
+
+import { getValidFilters } from "@/lib/data-table"
+import { Skeleton } from "@/components/ui/skeleton"
+import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"
+import { Shell } from "@/components/shell"
+
+
+import { searchParamsCache } from "@/lib/vendors/validations"
+import { getVendors, getVendorStatusCounts } from "@/lib/vendors/service"
+import { VendorsTable } from "@/lib/vendors/table/vendors-table"
+import { Ellipsis } from "lucide-react"
+
+interface IndexPageProps {
+ searchParams: Promise<SearchParams>
+}
+
+export default async function IndexPage(props: IndexPageProps) {
+ const searchParams = await props.searchParams
+ const search = searchParamsCache.parse(searchParams)
+
+ const validFilters = getValidFilters(search.filters)
+
+ const promises = Promise.all([
+ getVendors({
+ ...search,
+ filters: validFilters,
+ }),
+ getVendorStatusCounts(),
+ ])
+
+ return (
+ <Shell className="gap-2">
+
+ <div className="flex items-center justify-between space-y-2">
+ <div className="flex items-center justify-between space-y-2">
+ <div>
+ <h2 className="text-2xl font-bold tracking-tight">
+ 협력업체 리스트
+ </h2>
+ <p className="text-muted-foreground">
+ 협력업체에 대한 요약 정보를 확인하고{" "}
+ <span className="inline-flex items-center whitespace-nowrap">
+ <Ellipsis className="size-3" />
+ <span className="ml-1">버튼</span>
+ </span>
+ 을 통해 담당자 연락처, 입찰 이력, 계약 이력, 패키지 내용 등을 확인 할 수 있습니다. <br/>벤더의 상태에 따라 가입을 승인해주거나 PQ 요청을 할 수 있고 검토가 완료된 벤더를 기간계 시스템에 전송하여 협력업체 코드를 따올 수 있습니다.
+ </p>
+ </div>
+ </div>
+ </div>
+
+
+ <React.Suspense fallback={<Skeleton className="h-7 w-52" />}>
+ {/* <DateRangePicker
+ triggerSize="sm"
+ triggerClassName="ml-auto w-56 sm:w-60"
+ align="end"
+ shallow={false}
+ /> */}
+ </React.Suspense>
+ <React.Suspense
+ fallback={
+ <DataTableSkeleton
+ columnCount={6}
+ searchableColumnCount={1}
+ filterableColumnCount={2}
+ cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]}
+ shrinkZero
+ />
+ }
+ >
+ <VendorsTable promises={promises} />
+ </React.Suspense>
+ </Shell>
+ )
+}